TypeScriptにpluginがやってくる 作ってみよう編

## はじめに
さて、前回の[使ってみよう編](http://qiita.com/Quramy/items/1fc9aee235b79236775c)で、TypeScript 2.3 のLanguage Service Pluginの概要や使い方について記載しました。

Language Serviceにpluginが差し込めるようになったといっても、plugin自体が数える程度しか出回っていません。

無いなら作ればいいじゃない、ということで、今日はLanguage Service Pluginの実装方法を解説していきたいと思います。前半で基本的なpluginの構造を、後半で多少実践的な内容をtipsとして取り上げます。

なお、tipsの方については[自作のLanguage Service Plugin](https://github.com/Quramy/ts-graphql-plugin)が元ネタです。

## はじめてのLanguage Service Plugin
### Pluginのエントリポイントを作成する
Language Service Pluginのエントリポイントは次のような形式になります。

```ts:my-first-plugin/index.ts
import * as ts from 'typescript/lib/tsserverlibrary';

const factory: ts.server.PluginModuleFactory = (mod: { typescript: typeof ts } ) => {
  const pluginModule: ts.server.PluginModule = {
    create: create,
  };
  return pluginModule;
}

function create (info: ts.server.PluginCreateInfo): ts.LanguageService {
  return info.languageService;
}

export = factory;
```

`create` という名前の関数を用意して、そいつがLanguageServiceを返せばいいだけです[^1]。
plugin適用前のLanguageServiceは、create関数の引数に格納されて渡されるため、必要なメソッドだけ置き換えて返却します。

簡単ですね。

### Pluginに機能を追加する
このままだと何も意味がないpluginなので、機能を1つ追加してみます。

ここでは、getQuickInfoAtPosition というメソッドを置き換えてみます。

このメソッドは「現在のカーソル位置にある識別子がどのようなものなのか」を返す役割をもちます。平たく言えば、IDE側からはツールチップを実装するために呼びだされる機能です。

先述のindex.tsにおけるcreateメソッドを次のように書き換えてみます。

```ts:my-first-plugin/index.ts
function create (info: ts.server.PluginCreateInfo): ts.LanguageService {
  const ls = info.languageService;

  // オリジナルのメソッドを退避しておく
  const delegate = ls.getQuickInfoAtPosition;

  // tooltip用のメソッドを上書き
  ls.getQuickInfoAtPosition = (fileName: string, position: number) => {
    const result = delegate(fileName, position); // 元メソッドを呼び出す
    if (!result.displayParts || !result.displayParts.length) return result;
    // 結果を修正する
    result.displayParts = [
      { kind: "", text: " 🎉🎉 " },
      ...result.displayParts,
      { kind: "", text: " 🎉🎉 " },
    ];
    return result;
  };

  return ls;
}
```

ここでは結果を賑やかすために :tada: の絵文字を追加してみました。

### Pluginの動作を確認する
早速pluginを動かしてみましょう。
index.tsをtscでコンパイルし、`my-first-plugin/index.js` に出力しておきます。

pluginを利用する側のprojectをtest-my-first-pluginという名前のディレクトリに用意しておきましょう。
下記のディレクトリ構成を想定しています。

```text
+--- my-first-plugin/                    
|   |  node_modules/
|   |  index.js                          
|   |  index.ts                          
|   |  package.json                      
|   |  tsconfig.json                     
|   \  yarn.lock                         
|  
\--- test-my-first-plugin/               
    |  node_modules/
    |  main.ts                           
    |  package.json                      
    |  tsconfig.json                     
    \  yarn.lock                         
```

前回投稿でも記載したとおり、Language Serviceのpluginはtsconfig.jsonの`plugins`配列に設定します。

```json:test-my-first-plugin/tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "noImplicitAny": false,
    "sourceMap": false,
    "plugins": [
      { "name": "../my-first-plugin" }
    ]
  }
}
```

本来は`name` にはnpmのパッケージ名を指定するのですが、Node.jsの `require` よろしく相対パスで指定することも可能です。
~~ただし、1点だけ注意です。
`test-my-first-plugin`からではなく、`test-my-first-plugin/node_modules` からの相対パスで指定する必要があります。~~ (plugin探索パスの起点については、v2.5.2で修正されました)

さて、動作の準備が整いました。おもむろにVSCなどを立ち上げてみましょう[^2]。

```sh
cd test-my-first-plugin
code main.ts
```

![bNYlLcJIwl.gif](https://qiita-image-store.s3.amazonaws.com/0/8808/3a2b5c23-d295-22c6-5db6-b7bacf9d2add.gif)

見てのとおり、tooltipに :tada: が表示されました。

簡単ですね！

## Plugin開発 tips
ここからは、もう少し実践的な内容を書いておこうと思います。

### Pluginにオプションを渡す
plugin自体の挙動にオプションを設けて、ユーザーが設定可能にしたくなることもあるでしょう。

`ts.server.PluginCreateInfo` に生えているconfigプロパティにはtsconfig.jsonのpluginsセクションに記載しているpluginの情報が渡ってきます。

すなわち、tsconfig.jsonに次のように書いておくと、

```js:tsconfig.json
  "plugins": [
    { "name": "../../my-first-plugin", "hoge": "foo" }
  ]
```

下記のコードでオプションhogeの値が取得できます。
 
```ts
assert(info.config.hoge === 'foo');
```

### CLIからPluginを動作させる
Pluginの開発中に、機能の確認のためだけに一々エディタを立ち上げたりするのはとても非効率的です。
もちろんunit testを書いておいて、実エディタでの動作確認を最小に済ませるのは当たり前なのですが、e2e testはどうすんの？という疑問は残るでしょう。

そこで活躍するのがtsserverです。

使ってみよう編でも触れた通り、エディタがLanguage Serviceへアクセスする際は、大概はtsserverを経由します。
tsserverはLanguage Serviceをホストしたスタンドアロンなサーバプログラムです。
作成したpluginも動作しますし、標準入出力を介したコマンド送信が可能です。

詳細は[別エントリ](http://qiita.com/Quramy/items/c2835bc380c3e822d479)に書いているので、そちらを参照して欲しいのですが、さきほど作成したpluginの場合は、次のような入力ファイルを用意しておき、

```json:input.json
{ "seq": 0, "type": "request", "command": "open", "arguments": { "file": "main.ts", "fileContent": "const hoge = 1;", "scriptKindName": "TS" } }
{ "seq": 1, "type": "request", "command": "quickinfo", "arguments": { "file": "main.ts","offset":7, "line":1 } }
```

下記のコマンドでtsserverに流し込みます。

```sh
./node_modules/.bin/tsserver < input.json
```

すると、次のような出力が得られます。

```txt
Content-Length: 129
{"seq":0,"type":"event","event":"configFileDiag","body":{"triggerFile":"main.ts","configFile":"tsconfig.json","diagnostics":[]}}
Content-Length: 264
{"seq":0,"type":"response","command":"quickinfo","request_seq":1,"success":true,"body":{"kind":"const","kindModifiers":"","start":{"line":1,"offset":7},"end":{"line":1,"offset":11},"displayString":" 🎉🎉 const hoge: 1 🎉🎉 ","documentation":"","tags":[]}}
```

見辛いようであれば、jqなど使って適当に整形しましょう。

```sh
./node_modules/.bin/tsserver < input.json | tail -n 1 | jq .body > out.json
```

```json:out.json
{
  "kind": "const",
  "kindModifiers": "",
  "start": {
    "line": 1,
    "offset": 7
  },
  "end": {
    "line": 1,
    "offset": 11
  },
  "displayString": " 🎉🎉 const hoge: 1 🎉🎉 ",
  "documentation": "",
  "tags": []
}
```

tsserverを叩く方法を抑えておけば、Node.jsのchild process forkを使うなどすることで、簡単にe2e用のテストスクリプトを用意することもできます。

どんなコマンドがあるか分からない？ https://github.com/Microsoft/TypeScript/blob/v2.3.0/src/server/session.ts#L100 読めば大体分かるよ。

### Logを出力する
ところで、plugin作成中、「ちょっとdebug目的で...」などと軽い気持ちで `console.log(...)` を叩くのはNGです。
先述した通り、tsserverからpluginが呼びだされた際に標準出力が汚れてしまうと、VSCなどの連携するエディタ側が予期せぬ動きをする可能性があります。

ログ出力が行いたい場合、TypeScriptが用意しているloggerを利用しましょう。

```ts
const info: ts.server.PluginCreateInfo;
info.project.projectService.logger.info('ログ！');
```

tsserverのログ出力是非は環境変数 `TSS_LOG` で制御できます。

```sh
export TSS_LOG="-file tsserver.log -level verbose"
tail -f tsserver.log
```

### ASTから情報を引っこ抜く
Language Serviceのinterfaceを眺めてみるとわかりますが、多くのメソッドがファイル名を引数にとるように定義されています。

pluginが提供する機能は「いま開いている.tsファイルの構造を読み取って、情報を付与する」というケースが多いことを考慮すると、AST（抽象構文木）が欲しくなりますよね？むしろ欲しがれ。

こういった処理は `languageService.getProgram()` から `ts.Program` を持ってきてしまえば楽勝です。

```ts:ファイル名からASTを取得
const program = info.languageService.getProgram();
const source = program.getSourceFile('hogehoge.ts') as ts.Node;
// AST Nodeをvisitする処理など...
```

例えば「現在のカーソル上にあるNodeの情報を取得したい」であれば、次のように記述できます。

```ts
const program = info.languageService.getProgram();

export function findNode(sourceFile: ts.SourceFile, position: number): ts.Node | undefined {
  function find(node: ts.Node): ts.Node|undefined {
    if (position >= node.getStart() && position < node.getEnd()) {
      return ts.forEachChild(node, find) || node;
    }
  }
  return find(sourceFile);
}

const fileName = 'hogehoge.ts', position = 20;
const nodeOnCursor = findNode(program.getSourceFile(fileName), position);
```

## おわりに
このエントリでは、TypeScript Language Service Pluginの作成方法を述べてきましたが、如何でしたでしょうか。

tips等いくつかの内容を書いてきましたが、結局のところ既存pluginのコードを読むのが一番勉強になるのではと思っています。
個人的には下記の2つが分量的にも程よかったです[^3]。

- https://github.com/angelozerr/tslint-language-service
- https://github.com/sandersn/vue-ts-plugin

冒頭にも書いたとおり、まだまだpluginの数は少ないので、是非何か作成して公開してみてください。それでは、また。

*2017.04.20 追記*
@vvakame さんが https://github.com/vvakame/typescript-plugin-example のレポジトリにサンプルを公開してくれました。感謝です...!

*2017.05.24　追記*
英語ですが、TS公式wikiにもpluginの作り方が記載されています https://github.com/Microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin 

[^1]: `create` はrequiredですが、optionalとして `getExternalFiles` という関数を公開することも可能です
[^2]: vimでも動作確認済です
[^3]: Angularのpluginは高機能な分、読むには体力が必要なので除外しました