typescriptServices.jsについてのメモ

TypeScriptにはtypescriptServices.jsってのが同梱されてる。
TypeScriptはJavaScriptで書かれているので、ブラウザ上でのエディタとかもやろうと思えば簡単に出来るんだけど、まあそういうTypeScriptのエディタ的なものを支援するライブラリだ。


俺もjgforceとかでTypeScriptのエディタが必要だし、TypeScript 0.9に合わせてJavaScriptコンパイラを更新するにあたってせっかくだからtypescriptServices.js使うことにした。
したんだけど、まったく文献が無くて辟易したので、忘れないようにメモ。

各種デモ

とりあえずtypescriptServices.jsを使うとどんなことが出来るのかっていうのは、Microsoft公式のTypeScript Playgroundを見ると話が早い。
http://www.typescriptlang.org/Playground/


早いんだが、残念ながらPlayGroundはオープンソースじゃないし、難読化もされてるので何やってるのかよくわからん。わからんくらいならいいけど、typescriptServices.jsを使って本当にこれと同じものが出来るのか自体が疑問。


ということで、俺も勉強を兼ねてjgame.jsのPlyaGroundをtypescriptServices.jsを使った形に更新してみた。
http://jgame-js.sourceforge.jp/playground/normal.html *1
入力補完系はさすがに公式よりクォリティが少し落ちるけど、機能としては同じレベルなので、「出来る」という証明にはなってると思う。


PlayGround自体の以前からの変更点としては、typescript 0.9になっているのと、Ctrl+Spaceで入力補完が出るようになっているというところ。
諸事情でPlayGroundのオープンソース化は考えてないんだけど、javascriptソースの流用は基本的に黙認するのでご自由に。TypeScript版のソースが欲しいとかって人はメールでもください。


これを作るにあたって、主にAceでオートコンプリートを出すやり方がよくわからなかったので、以前も触れたTypeScript Playground on Aceっていうプロダクトのソースコードも参考にさせていただいた。
https://github.com/hi104/typescript-playground-on-ace
これは0.8系だけど、やはりtypescriptServicesを使っているので、必要に応じて参考にしてもらえれば。

typescriptServices.jsの使い方はやめぐり

解説してると長くなりすぎるので、使い方をざっと。

ScriptSnapshotの作成

多分高速コンパイルのため文書の差分を管理するための機構だろうけど、typescriptServicesであらゆることをやるためには、ScriptSnapshotってのが必要。
version 0.9現在、組み込みのScriptSnapshotではgetTextChangeRangeSinceVersionってのが未実装だよという例外を投げてくるので、これを継承した独自のScriptSnapshotクラスを最初に作っておく。


jgame.jsのPlayGroundの実装はこんな感じ*2

export class JgScriptSnapshot implements TypeScript.IScriptSnapshot {
	text: string;
	oldText: string;
	constructor(text: string, oldText: string) {
		this.text = text;
		this.oldText = oldText;
	}
	getText(start: number, end: number): string {
		return this.text.substring(start, end);
	}

	getLength(): number {
		return this.text.length;
	}

	getLineStartPositions(): number[] {
		return TypeScript.TextUtilities.parseLineStarts(
			TypeScript.SimpleText.fromString(this.text)
		);
	}

	getTextChangeRangeSinceVersion(scriptVersion: number): TypeScript.TextChangeRange {
		var span = new TypeScript.TextSpan(
			0,
			this.oldText ? this.oldText.length : this.text.length
		);
		return new TypeScript.TextChangeRange(span, this.text.length);
	}
}

差分とるのが面倒なので、getTextChangeRangeSinceVersionは「全変更されてるよ」ってのを返してる(つもり)。
その他のメソッドはTypeScript側のStringScriptSnapshotクラスと同じ。ちなみにStringScriptSnapshotはなぜかprivateなので継承出来ない。

ILanguageServiceHostの実装クラスを作成

続いてServices.ILanguageServiceHostインターフェースの実装クラスが必要。
こういうインターフェース。

interface ILanguageServiceHost extends TypeScript.ILogger {
	getCompilationSettings(): TypeScript.CompilationSettings;
	getScriptFileNames(): string[];
	getScriptVersion(fileName: string): number;
	getScriptIsOpen(fileName: string): boolean;
	getScriptSnapshot(fileName: string): TypeScript.IScriptSnapshot;
	getDiagnosticsObject(): Services.ILanguageServicesDiagnostics;
}

interface ILogger {
	information(): boolean;
	debug(): boolean;
	warning(): boolean;
	error(): boolean;
	fatal(): boolean;
	log(s: string): void;
}

要はコンパイルの設定とか、現在オープンされてるスクリプトの一覧とかを管理するもの。
あとILoggerを継承したインターフェースなのでログ出力メソッドも必要。


こいつを、まあ頑張って作る。ソース貼ると長くなりすぎるから頑張って作ってくれ。
jgame.jsのPlyaGroundでは定義ファイルをあらかじめ読み込み、編集するファイルは常に一つという形なので、定義ファイルとして[n].d.ts(nは連番)ってのと編集対象のmain.tsってファイルを仮想的に管理している形にして、[n].d.tsのスナップショットやバージョンは常に固定、main.tsはバージョンやスナップショットを必要に応じて切り替えるようにしてた。


一部抜粋するとこんな感じ。jgeditor.fileは"main.ts"*3

addDefine(script: string) {
	this.defines.push(script);
	this.defineSnaps.push(TypeScript.ScriptSnapshot.fromString(script));
}

setScript(script: string) {
	this.snapshot = new JgScriptSnapshot(this.script, script);
	this.script = script;
	this.version++;
}

getScriptFileNames(): string[]{
	var scripts:string[] = [];
	for (var i = 0; i < this.defines.length; i++)
		scripts.push(i + ".d.ts");

	scripts.push(jgeditor.file);

	return scripts;
}

getScriptVersion(fileName: string): number {
	if (fileName != jgeditor.file)
		return 1;

	return this.version;
}

getScriptIsOpen(fileName: string): boolean {
	return (fileName == jgeditor.file);
}

getScriptSnapshot(fileName: string): TypeScript.IScriptSnapshot {
	if (fileName == jgeditor.file)
		return this.snapshot;

	var defineIndex = Number(fileName.substr(0, fileName.indexOf(".")));
	return this.defineSnaps[defineIndex];
}

定義ファイルはまあ適当で、メインのスクリプトのバージョンインクリメントとかをちゃんとやればさほど難しくないはず。
ちなみにTypeScriptILoggerのdebugとかerrorメソッドとかは対象ログを出力するかどうか判断して、logメソッドで実際に出力するので、debugとかerrorとか全部trueでlogメソッドはconsole.logやっとけばとりあえず動く。


あとようわからんのがgetDiagnosticsObjectだけど、これはnullでOK。
getCompilationSettingsはコンパイル設定だけど、これも確かnullでデフォルト設定使うはず。nullで駄目だったらnew TypeScript.CompilationSettings()でも返しておけばよし。

ビルド準備

用意したクラスを使ってオブジェクト生成。
こんな感じ。

var factory = new Services.TypeScriptServicesFactory();
var host = new JgPlayground(); //さっき作ったクラスのインスタンス
//lib.d.tsとか、必要な定義ファイル追加。this.host.addDefine(...);
//スクリプト設定 host.setScript(...);
var service = factory.createPullLanguageService(host);

なんか専用のfactoryが用意されてるので、それのcreatePullLanguageServiceにさっき作ったILanguageServiceHostの実装クラスのインスタンスを渡してやれば、コンパイルとか一式をしてくれるオブジェクトが生成出来る。
以後このオブジェクトのことは"service"と呼ぶ。

文法、型チェック

文法のエラーservice.getSyntacticDiagnosticsで、型エラーはservice.getSemanticDiagnosticsでとれる。

var syntaticDiagnositcs = service.getSyntacticDiagnostics(jgeditor.file);
if (syntaticDiagnositcs.length) {
	//文法エラー
}
var semanticDiagnostics = service.getSemanticDiagnostics(jgeditor.file);
if (semanticDiagnostics.length) {
	//型エラー
}

ただちょっと厄介なことに、これだけだと行番号がとれず、英語のメッセージがとれるだけ。
行番号をとるには、またScriptSnapshotのお世話になる。こんな感じ。

var semanticDiagnostics = service.getSemanticDiagnostics(jgeditor.file);
if (semanticDiagnostics.length) {
	semanticDiagnostics.forEach((diagnostic) => {
		var len = snapshot.getLength();
		var lineMap = new TypeScript.LineMap(
			snapshot.getLineStartPositions(),
			len
		);
		var lineNumber = lineMap.getLineNumberFromPosition(diagnostic.start())+1;
		var message = diagnostic.message();
		console.log(lineNumber + ": "+ message);
	});
}

この例は簡略化しているけど、たとえばsyntaticDiagnositcsは行番号に関係ないエラーが出る場合があるので、lineMap.getLineNumberFromPosition時に例外が出る可能性があるので注意。


まあ細かい仕上げは必要だけど、これでエラーはとれるということで。
なお必要な作業(たとえばビルドとか)はgetSemanticDiagnosticsを呼んだときに自動的に行われるので、こちらは気にしないでいい。

JS出力

TypeScriptからJavaScriptの出力は、実はあんまりよくわかってない。誰か詳しい人いたら補足してほしい。
とりあえずこれでとれる。

var emitOutput = service.getEmitOutput(jgeditor.file);
emitOutput.outputFiles[0].text;	//javascript

ただemitOutput.outputFilesが複数ファイルになる条件とかがあんまりわかってないので、不足の場合もあるかも。
単一ファイルに対するgetEmitOutputで複数ファイルになるケースはないと思うんだけどね。


これもビルドは自動なので、特に気にせずgetEmitOutputしてよし。

型情報や入力補完情報の取得など

実際にエディタを使っていると、型情報とかがほしくなる。
一応この辺でとれる。すべてserviceのメソッド

メソッド名 概要 戻り値 引数s
getTypeAtPosition 型情報取得 TypeInfo fileName: string, position: number
getCompletionsAtPosition 入力補完情報取得 CompletionInfo fileName: string, position: number, isMemberCompletion: boolean
getCompletionEntryDetails 入力補完候補詳細情報取得 CompletionEntryDetails fileName: string, position: number, entryName: string
getIndentationAtPosition インデント情報取得 number fileName: string, position: number, editorOptions: EditorOptions
getSignatureAtPosition 引数情報取得 SignatureInfo fileName: string, position: number

個別に解説していくと長くなりすぎるのでざっくりと。


getTypeAtPositionは、Typescript公式のPlaygroundで、マウスホバーで型情報をポップアップ表示するのに多分使ってる。
getCompletionsAtPositionとgetCompletionEntryDetailsは入力補完候補。getCompletionsAtPositionで一覧取得、getCompletionEntryDetailsで詳細取得。
getIndentationAtPositionはインデント数。スマートなインデントにどうぞと。
getSignatureAtPositionはメソッドの引数情報。たとえばa.b()ってやったとき、a.b(でCtrl+Spaceとか押すと、bメソッドの引数情報を知らせてくれたりするもの。


それぞれの詳細は、まあ戻り値をデバッガででも追ってみてもらうのがいいかなと。


getOccurrencesAtPositionってのもあって、先述の「TypeScript Playground on Ace」のリファクタリング機能がそれで作られているって言えばなんとなく想像つくかと。TypeScript Playground on Aceのは不完全な印象を受けるけど、リファクタリング機能搭載するエディタを作るなら見る価値あり。
あとgetReferencesAtPositionとgetDefinitionAtPositionってのも使えそうなんだけど、詳しくは調べてない。


で、これらのメソッドは全体的に癖があって、引数のfileNameはいいとして、positionってのがどれにも必要。
このpositionってのは文字数。
行番号や列番号ではないので、たとえばAceでやる場合は行番・列番をeditor.getCursorPosition()でとってから、editor.getSession().getDocument().positionToIndex(cursor)とかやって変換しないといけないってのが一つ。


それから、positionってことは原則として今のエディタのキャレット位置と、typescriptServicesが認識しているスクリプトのキャレット位置が合っている必要があるので、あらゆる情報をとるために事前ビルドが必須になる。
ビルドは同期的に行われるためどうしても重くなるので、Typescript Playground on Aceでは「.」の入力でも自動的に入力補完を表示していたのに対して、貧弱なPCしかない俺の場合あえてその操作を除去していたりする。


この辺の仕上げは好みによるだろうけど、事前ビルド必須 = 重くなることもあるよということで、要注意。
ScriptSnapshotで差分をうまく管理してあげれば、ビルド処理自体省けるかもしれないけど。

typescriptServices.jsの定義ファイル

TypeScriptでtypescriptServices.jsを開発する場合、定義ファイルが欲しいんだけど、多分無い。俺も結構探したんだけど。
ということで以下いずれか。

  1. 自分でビルドして定義ファイルを作る
  2. 必要なところだけ定義ファイル化する
  3. 人のを拝借する


俺は[2]でやった。
[3]はたとえばこういうのあるけど、
https://github.com/alvivi/TypeScript-0.9.0a/blob/master/bin/typescriptServices.d.ts
https://github.com/jaydata/jaydata_next/blob/master/bin/typescriptServices.d.ts
どれもちょいと古いので参考程度。


そのうち公式が出るのを期待するか、おとなしくjavascriptとかで開発するしかない模様。

コンパイラの情報

TypeScriptCompiler、いわゆるtscについてもTypeScript 0.9系の情報が結構少ない。
俺の場合下記コードを参考にさせてもらった。
https://github.com/k-maru/grunt-typescript


TypeScriptもまだ安定してないので、多少参考にしつつ不足するところはtscのソース直接読むしかないかもしれない。

この記事について

アップする場所が不明だったのでとりあえずBlogにあげたけど、この記事は情報量の少ないtypescriptServices.jsの共有をしたいためだけに書いたので、内容はすべて転載自由で著作権表記とかもいらないです。
いわゆるNYSDLで。
http://www.kmonos.net/nysl/nysdl.ja.html


typescriptServices.jsの情報不足に苦しむ人が少しでも少なくなることを祈っております。




2013年08月23日追記。
TypeScript 0.9.1.1はコンパイラの内容が少し変わっているので、この記事のままでは使えません。
このファイルなどではTypeScriptコンパイラの最新版をサポートしているので、参考になれば幸いです。
https://github.com/tsugehara/jgeditor/blob/master/ts/JgPlayground.ts

*1:このURLの内容はこの記事執筆時点から変更される可能性あり

*2:2013/07/23 oldText追加

*3:2013/07/23 ScriptSnapShotの修正に伴いJgScriptSnapshot生成方法を変更