概要
前回の記事は、タイトルに書いたことを実現するための検討がメインの内容でした。今回は検討結果に基づく実装中心の内容となります。これらの情報が少しでもお役に立てば幸いです。
開発環境
- Windows10 Pro 1909
- VSCode 1.47
- JDK 1.8
- Xtext 2.24
- LSP4J 0.10.0
課題
前回からの再掲ですが課題は
- VSCodeのワークスペース下にあるjsonファイルの更新通知をハンドルすること
- jsonファイルのフォーマットは、keyはnames、valueは文字列であること
- jsonファイルが更新された場合、サーバーはそれを参照するmydslファイルをバリデーションすること
- バリデーション時、サーバーは参照するjsonファイルが保持する情報を読み取り、その結果をメモリに保持すること
- ユーザーから入力補完が指定された場合、jsonファイルから読み取ったvalueを入力補完の候補として表示すること
- 参照するjsonファイルから情報を読み取りにある程度の時間を要する場合、サーバーは進捗状況を表示すること
- mydslファイルはjsonファイルを参照可能な文法になっていること
です。
結果
VSCodeから入力補完を行った結果…
となりました。
name
という標準の選択肢が出ている点はあるものの、入力補完の候補にjson内で定義されたものが表示されています。
inviteキーワードにも色が付きました。
詳細
ポイントだけに絞って書きます。
ソースコード全体とすべての変更点を確認したい方はGitHubを見てください。
mydslファイルはjsonファイルを参照可能な文法にする
折りたたみ表示に対応したときと同じように、BlockとBlock内の要素の2段階で定義します。
具体的には、invite1行に当たるInvitationルールと、Invitationが1以上からなるInvitationBlockルールと、InvitationBlockルールは未定義でもエラーとならないよう最上位ルールのModelには0以上、を追加します。
Invitationルールは、キーワードinvite
に続いて文字列を受け付けるように定義します。文字列をファイルパスとして扱う部分はバリデーション時に行うこととします。
なお、InvitationBlockは今回の例では定義することは必須ではないのですが、今後inviteの折りたたみ表示をサポートする場合を考慮して、予め定義しておきます。
Model:
(invitationBlock=InvitationBlock)?
greetingBlock=GreetingBlock
;
InvitationBlock:
(invitaions+=Invitation)+;
(略)
Invitation:
'invite' list=STRING;
シンタックスハイライトでinviteに色を付ける
この拡張機能でのシンタックスハイライトの定義は、vscode-extension-self-contained\syntaxesフォルダの、mydsl.tmLanguage.jsonにあります。抜粋すると現状は
{
(略)
"keyword": {
"name": "keyword.control.mydsl",
"match": "\\b(Hello|from)\\b|!"
}
(略)
}
な感じになっています。
Hello
とfrom
がキーワード指定されていることから、ここに|inviteを足すことで色付けは完了します。
なお、シンタックスハイライトは、xtendのVSCode拡張機能のページにもあるとおり、さまざまなツールを駆使すればできるようです。1回は自分で書いてみたほうが学びがありそうなので取り組んでみたいものの、色付けの上書きは意外と大変そうでなので、次のテーマとしたいと思います。
ワークスペース下にあるjsonファイルの更新をハンドルする
jsonファイルの更新時に、LanguageServerImplクラスのdidChangeWatchedFilesメソッドが呼ばれるのは調査済みです。didChangeWatchedFilesメソッドの先をバリデーションにつなげたいので、
@Override
public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) {
runValidatable(() -> toBuildable(params));
}
のように、runBuidableメソッドから、バリデーションまでを呼ぶように変更したrunValidatableメソッドの呼び出しに変えます。あわせてDidChangeWatchedFilesParamsを引数に受けるtoBuildableメソッドも
/**
* Evaluate the params and deduce the respective build command.
*/
protected Buildable toBuildable(DidChangeWatchedFilesParams params) {
(略)
return workspaceManager.didChangingFiles(dirtyFiles, deletedFiles);
}
のように、didChangedメソッドからFiledidChangingFilesメソッドの呼び出しに変更します。
jsonファイルを参照するmydslファイルをバリデーションする
didChangingFilesメソッドの呼び出し後、変更のあったファイルに対してバリデーション実行を指示する役割を担うのは、BuildManagerです。さらに変更のあったファイルを実際に処理しているのは、internalValidateメソッドです。
protected List<IResourceDescription.Delta> internalValidate(CancelIndicator cancelIndicator) {
List<URI> allDirty = new ArrayList<>(dirtyFiles);
Multimap<ProjectDescription, URI> project2dirty = HashMultimap.create();
for (URI dirty : allDirty) {
//ここの処理を変更
}
}
前回の記事で、ここの処理を変更
すれば良いとしたとおり、jsonファイルを参照しているすべてのmydslファイルを抽出する処理が必要です。それをgetAffectedメソッドとしてWorkspaceManagerクラスに追加します。
List<URI> affedted = workspaceManager.getAffected(dirty);
for (URI ref : affedted) {
project2dirty.put(workspaceManager.getProjectManager(ref).getProjectDescription(), ref);
}
なお、getAffectedメソッドではmydslファイルを直接参照する処理をWorkspaceManagerに実装するのは避けました。WorkspaceManagerという名前以上の責務を担うことになると考えたためです。そこで、Xtextファイルを読み取ることに特化した新規のExternalContentクラスに任せることにします。
public List<URI> getAffected(URI dirty) {
ExternalContent ec = new ExternalContent();
List<URI> affedted = ec.getAffected(dirty, getXtextResource());
return affedted;
}
ExternalContentクラスについてはGitHubのコードを確認してください。
バリデーション時、サーバーは参照するjsonファイルを読み取る
バリデーションを担っているのは、MyDslValidatorクラスです。Xtextのバリデーションは、文法で定義したルール毎にチェック用のメソッド(@Checkをメソッドに付与する)を定義可能なようになっています。
ここでは、inviteでファイルが1行以上指定された場合と、inviteが1行も指定されない2つのケースを考慮した実装とします。ルールの定義次第なところはありますが、2つのケースを考慮する理由は、ルールに合致する記述をユーザーが入力していない場合、チェック用のメソッドが呼ばれないためです。
inviteルールに合致する記述がない場合に備え、inviteルールを包含するModelルールのチェック用のメソッドを追加して記述がない場合を扱えるようにします。
@Check
public void checkModel(Model model) {
InvitationBlock invitations = model.getInvitationBlock();
if (invitations == null) {
Map<String, Person> persons = new HashMap<String, Person>();
InvitationHolder.INSTANCE.set(persons);
}
}
@Check
public void checkInvitationBlock(InvitationBlock invitations) {
URI xrUri = invitations.eResource().getURI();
String xPath = toAbsolutePath(xrUri);
Path p = Paths.get(xPath);
Path parent = p.getParent();
List<String> list = getInvitationList(invitations.getInvitaions());
List<String> paths = getInvitationListPath(list, parent);
for (String path : paths) {
Map<String, Person> persons = new HashMap<String, Person>();
try {
ObjectMapper mapper = new ObjectMapper();
Person i = mapper.readValue(new File(path), Person.class);
persons.put(path, i);
} catch (Exception e) {
e.printStackTrace();
}
InvitationHolder.INSTANCE.set(persons);
}
}
なお、jsonファイルの読み込みは、jacksonを使うことにします。
標準では使えないので、gradleで取得できるように記述を追加します。執筆時点の最新である2.13.0を使うことにします。
dependencies {
(略)
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind
compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.13.0'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core
compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.13.0'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-annotations
compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: '2.13.0'
}
入力補完が指定された場合、jsonファイルから読んだ文字列をその候補として表示する
入力補完が指定された場合に呼び出されるクラスは、MyDslIdeContentProposalProviderクラスです。これの実体はMyDslIdeContentProposalProvider.xtendに実装されています。xtendの文法が頭に入っている人であれば問題ないと思うのですが、xtendはVSCodeの編集支援が得られる現状にないので、この機にjava化します。
下準備
java化は簡単です。
この時点でxtend-gen\org\xtext\example\mydsl\ide\contentassistフォルダ内に、MyDslIdeContentProposalProvider.javaファイルが生成されています。そのファイルをxtendファイルがある、src\org\xtext\example\mydsl\ide\contentassistフォルダにコピーします。
その後、MyDslIdeContentProposalProvider.xtendファイルに定義されたクラス名を
package org.xtext.example.mydsl.ide.contentassist
class UnusedMyDslIdeContentProposalProvider extends IdeContentProposalProvider {
(略)
}
のように元のクラス名とは異なるものに変更します(ここではクラス名の頭にUnusedを加えています)。これにより次にビルドの際、xtend-genフォルダにUnusedMyDslIdeContentProposalProvider.javaファイルが生成されますが、どの処理からも参照されないこともありエラーも出ません。変更量を少なくするためにこの方法としましたが、気になる場合はxtendファイル自体を削除するのがいいでしょう。
_createProposalsメソッドの変更
上でコピーした、MyDslIdeContentProposalProvider.javaファイルを変更します。コピー直後は次のように
@Override
protected void _createProposals(RuleCall ruleCall, ContentAssistContext context,
IIdeContentProposalAcceptor acceptor) {
if ((Objects.equal(this._myDslGrammarAccess.getGreetingRule(), ruleCall.getRule()) && (context.getCurrentModel() != null))) {
(略)
}
super._createProposals(ruleCall, context, acceptor);
}
_createProposalsメソッドが実装された状態です。
省略しているので記事上では分かりらないのですが、実際のコードではいかにも機械的に生成されたなという印象を受けますが、それはさておき、最初のif文のブロックの下に、IDの位置で入力補完が指定された場合を条件に新規のif文のブロックを足すことにします。
ルール名とコンテキストで区別するのが基本で、次のようなコードになります。
if ((ruleCall.getRule().getName().equals("ID")) && (context.getCurrentModel() instanceof Greeting)) {
IdeContentProposalCreator _proposalCreator = this.getProposalCreator();
for (String s : InvitationHolder.INSTANCE.get().keySet()) {
Person p = InvitationHolder.INSTANCE.get().get(s);
for (String name : p.names) {
final ContentAssistEntry entry = _proposalCreator.createProposal(name, context);
acceptor.accept(entry, 0);
}
}
}
終わりに
- 要点さえわかれば独自実装でもそこそこ早く実現できたので良かったですが、isAffectedメソッドで対処する方法を追求したかったですね
- バリデーションと入力補完で共通で参照する結果を保持するものとして、シングルトンを意識した実装で実現してみたものの、もっといい方法がありそうな気がします
- tmLanguage.jsonの編集はツールもあるようだし、時間をかけて学んでみたいです