どうも @Quramy です。
さて、今日も今日とてAngular + エディタ関連ネタです。そろそろAngular 2というと怒られるらしいのでAngularと書きます。
先に成果を見てもらうのが手っ取り早いですね。コイツを見れ。
見ての通り、Angular Componentのテンプレート中でプロパティ名補完とエラーチェックを行えるようにしてみました。
上図はVimのキャプチャですが、もし、これを読んでる貴方がVisual Studio Codeユーザーであるならば、これ以降は全く読まなくてよいです。https://github.com/angular/vscode-ng-language-service にVSC用のpluginが転がっているので、こいつを入れればいいさ。
今日の想定読者は、Emacs, Vim, Sublime Text あたりを使ってAngularのコードを書いている人たちです1。
何故こんな縛りがあるかというと、これらのエディタは共通して tsserver2 というTypeScriptにバンドルされた言語サポートサービスを利用しており、今回はこいつに侵襲する話だからです。
思い返すと2015年の今頃もTypeScriptを魔改造してjspmのモジュールをimport出来るようにする 等という記事を書いていました。僕は年の瀬になるとTypeScriptを改造したくなる性癖があるようです。
インストール方法
中身の仕組みは後述しますが、時間や興味があまり無い人のために適用手順だけ貼っておきます。
- ターミナルを開いて(angular-cli等で作った)プロジェクトのルートディレクトリに移動
-
npm install typescript @angular/language-service reflect-metadata
で必要なNPMパッケージをインストール - 以下のコマンドを実行
curl 'https://raw.githubusercontent.com/Quramy/ng-tsserver/master/ng-tsserver' | bash
これで2.でインストールされたtsserverにパッチがあたり、Angularのエディタサポートが追加されます。
大概のTypeScriptエディタプラグインは、tsserverの場所を設定で指定できるようになっているので、それぞれのエディタ設定でローカルインストールされているtsserverを利用するように設定してください3。
どのように実現しているか
ここからは、今回の機能をどのように実装したかの自慢話です。
まず、Angular向けの言語サポート機能については、@angular/language-serviceというNPMパッケージを利用しています。@angular
と付与されていることからも分かるように、Angularコアチーム謹製で、2.3系から追加されました。ちなみにchuckjazさんが制作者です。主にCompiler周りを作ってる方ですね。
ところでこのモジュール、Angularに興味があってかつエディタのプラグイン製作をやってる人間しか直接触ることが無いという事情のためか4、READMEすらない有様です。
使い方が全く分からなかったので、適当に.d.tsを読み漁っていると、下記を見つけました。
import * as ts from 'typescript';
/** A plugin to TypeScript's langauge service that provide language services for
* templates in string literals.
*
* @experimental
*/
export declare class LanguageServicePlugin {
private serviceHost;
private service;
private host;
static 'extension-kind': string;
constructor(config: {
host: ts.LanguageServiceHost;
service: ts.LanguageService;
registry?: ts.DocumentRegistry;
args?: any;
});
/**
* Augment the diagnostics reported by TypeScript with errors from the templates in string
* literals.
*/
getSemanticDiagnosticsFilter(fileName: string, previous: ts.Diagnostic[]): ts.Diagnostic[];
/**
* Get completions for angular templates if one is at the given position.
*/
getCompletionsAtPosition(fileName: string, position: number): ts.CompletionInfo;
}
見るからにTypeScriptのLanguageServiceと親和性のありそうな定義ですね。実際、TypeScript本体の.d.tsをひも解くと以下の記述があります。
namespace ts {
// 一部抜粋
function createLanguageService(host: LanguageServiceHost, documentRegistry?: DocumentRegistry): LanguageService;
interface LanguageService {
getSyntacticDiagnostics(fileName: string): Diagnostic[];
getCompletionsAtPosition(fileName: string, position: number): CompletionInfo;
// 他にも色々メソッドが生えている
}
}
ということで、次のプランで進めればtsserverにAngular LanguageServiceを統合できそうです。
- TypeScriptの
createLanguageService
を乗っとっる - オリジナルの
createLanguageService
で作成したLanguageServiceに対して、AngularのLanguageServiceが持つメソッドでラップ - tsserverに2.でラップしたcreateLanguageServiceを利用させる
さて、問題となるのは3.の部分です。TypeScript本体をforkして自前でビルドする、という手段も考えたのですが、他の人に使ってもらうには厳しいやり方です。
また、tsserverはrequireした途端に起動する挙句に、外部からオプションが渡せるような仕組みも特にありません。
一応、tsserverlibrary.jsというのもTypeScriptに同梱されているのですが、こいつは肝心のserver部分を自分で実装しないと使えない代物なので今回は却下。
TypeScript本体の.jsは、全て ts
という名前空間(内部モジュール)の中に実装されているので、事前に ts.createLanguageService
に Property Descriptorを仕込んでおくという手段を用いました。
const NgLanguageServicePlugin = require("@angular/language-service")().default;
if (typeof ts === "undefined") ts = {};
var delegate;
Object.defineProperty(ts, "createLanguageService", {
get: function() {
return function(host, documentRegistry) {
const ls = delegate(host, documentRegistry);
const nglsp = new NgLanguageServicePlugin({
host: host,
service: ls,
registry: documentRegistry
});
// オリジナルのメソッドを退避
const completionFn = ls.getCompletionsAtPosition;
const samnticCheckFn = ls.getSemanticDiagnostics;
// LanguageServiceのメソッドをラップ
ls.getCompletionsAtPosition = (filename, position) => {
const ngResult = nglsp.getCompletionsAtPosition(filename, position);
if (ngResult) return ngResult;
return completionFn(filename, position);
};
ls.getSemanticDiagnostics = (fileName) => {
return nglsp.getSemanticDiagnosticsFilter(fileName, samnticCheckFn(fileName));
};
return ls;
}
},
set: function(v) {
delegate = v;
},
configurable: true,
enumerable: true
});
var ts; // tsserver.jsに定義されてる
このようにしておくと、tsserverの実装の中で、ts.createLanguageService
が呼びだされる箇所では、上記のgetterが動作して、ラップ済みのLanguageServiceが返却されるようになります。
インストール手順で実行したシェルスクリプトは、上記の.jsに対して、インストール済みのtsserver.jsをappendして置き換えるだけの仕組みということです。
(function(ts) {
// tsserverの本体実装
// ↓みたいのがどっかにいる
// ts.createLanguageService = function() {...};
})(ts || {}); // この時点で ts.LanguageServicePluginにはProperty Descriptorが適用されている
おわりに
今回は、AngularのLanguageServiceをTypeScriptのLanguageServiceに統合してエディタから利用可能にする方法について記載しました。
AngularのLanguageServiceはAoTコンパイルと同様に、@angular/compilerをオフラインで実行する機能です。
以前にAngular AoTガイドにて、次のように書きました。
上述したように、ngcコマンドを利用するとHTMLテンプレートから.ngfactory.tsを出力します。bundleを作成する際にはこの.ngfactory.tsをJavaScriptにトランスパイルし、モジュールバンドラでbundleファイルを作成する、というビルドフローが発生します。
言い換えると、ビルドフローの中でHTMLテンプレート(に相当するTypeScript)に対して型がチェックされるという意味になります。
エディタ + LanguageServiceを使えば、ビルドよりもさらに早いタイミングでテンプレートのチェックが可能になるわけですから、利用しない手は無いですね!
正直、node_modules配下の.jsに手を突っ込むというのが相当キワモノであることは自覚しているので、より良い統合方法を知っている方がいたら、コメントやPRで教えてくれると嬉しいです。
追記 (2017.04.16)
TypeScript 2.3.xから導入される Language Service Pluginにて、LanguageServiceの差し替えが出来るようになったため、上記で紹介しているような方法を取る必要は最早ありません。
詳細はこちらのエントリに記載しています。
脚注
-
少し古いですが、 http://angularjs.blogspot.jp/2015/09/angular-2-survey-results.html を見るとVimやSublime勢がそれなりの比率で存在していることが分かります ↩
-
Tsuquyomi(Vim)の場合、デフォルトでローカルインストールされたtsserverを利用するので設定不要。tide(Emacs)の場合は、https://github.com/ananthakumaran/tide#faq を参照のこと。 ↩
-
多分、世界でも10人程度しか興味を持ってる人がいないんじゃないだろうか。 ↩