Edited at

ざっくりとイメージをつかむための Visual Studio Code 拡張機能開発入門

試しに Visual Studio Code 拡張機能開発でタスク管理ツールをつくってみた。

拡張機能の開発方法はドキュメント Visual Studio Code Extension API を熟読すれば事足りるが、英語だし、そもそも前提知識ないしで色々苦労したので、備忘録兼ねて要点をまとめておきたい。


前提


  • Windows 10 を使っていること

MacOS 10.14(Mojava) でも開発はしていますが、基本的に Windows 使いなので Windows を前提とさせてください。


  • Visual Studio Code を使っていること

コマンドパレットは何か、など Visual Studio Code の基本的な使い方はわかっているものとします。


拡張機能開発環境セットアップ


node.js と npm

node.js をインストールする。公式サイトからインストーラーをゲットして実行する。

npm をインストールする。node.js に付属している。

終わったら動作確認。

$ node -v

v10.16.0

$ npm -v
6.9.0


npm から Yeoman

npm から yo と generator-code をインストールする。

$ npm install -g yo

$ npm install -g generator-code

これは Yeoman というテンプレートジェネレータと、VSCode 用の Yeoman テンプレートデータを入れている。これで yo code コマンドを実行するだけで Hello World プロジェクトを簡単につくれるようになる。


プロジェクトの作成

yo code コマンドを実行する。

何をつくるかを選べる。


  • New Language Support(言語定義)

  • New Color Theme(テーマ)

  • New Extension(拡張機能)

つくるものを選んだら、さらにプロジェクト名や作成者名などを入力させられる(あとで変えられる)。これも終わったら、プロジェクトファイル一式がつくられるので、あとはそのディレクトリに移動して VSCode を起動すればよい。

$ yo code

(new-extension-hoge をつくったとする)

$ cd new-extension-hoge

$ code .


ハマリどころ: Proxy 環境下だとキツイです

Proxy がガッツリ入っているようなキツイ環境下だと、yo code が動かない。

「ちゃんと HTTP_PROXY 環境変数やら npm config set proxy %HTTP_PROXY% やら VSCode settings.json の http.proxy やら設定すれば行けるのでは?」と思いがちだけど、そう甘くもない。

(これは推測だが)そもそも昨今の OSS は依存関係等で迷宮みたいに通信走らせているので、キツイ環境下だと何かしらひっかかってよくわからんエラーで上手く動かない率が高い。

最後に私が試行錯誤して見つけた情報源の一部を置いておく。


開発手順

開発、テスト、デバッグ、公開までの流れや大まかな手順をざっくりと。


初回時

他のリポジトリから clone した場合など、最初は「そのプロジェクトでの開発に必要な npm パッケージ群」がインストールされていない。

$ cd (拡張機能のプロジェクト)

$ npm install

でインストールしてやる。

これを行うと node_modules というディレクトリができて、ここにパッケージ群が入る(容量でかい)。


開発時

VSCode を開く。

$ code .

ドットは必要。「カレントディレクトリを基点にして code(VSCode) を開きますよ」という意味になる。

VSCode が開いたら、VSCode 上でコードを書いていく。


デバッグ実行

F5 を押す。

新たな VSCode ウィンドウが立ち上がるはず。

デバッグログなどは元々の VSCode ウィンドウ側のデバッグコンソール上で見れる。コードに書いた console.log() の出力もここで見れる。


ユニットテスト

今回試してないのでよくわからん。


パッケージング

vsce というツールが必要。npm でインストールする。

$ npm install -g vsce

パッケージングは以下で実行できる。

$ vsce package

これを行うと .vsix というバイナリファイルが生成される。

(!) Proxy 環境などキツイ環境下では vsce package が動作しない(ブロッキングして応答が返ってこない)ことがある。


.vsix ファイルをインストールする

vsix ファイルのインストールは Command Palette > Install from VSIX あるいは code --install-extension xxxxx.vsix コマンドで行える。

インストールしたらサイドバー > 拡張機能 > @installed と入力して、当該拡張機能がインストールされているか確かめる。また、有効になっている拡張機能は @enabled でわかる。

アンインストールは、VSCode 上でやるなら上記 @installed で表示したところからギアアイコン > Uninstall を。コマンドでやるなら code --uninstall-extension xxxxx.vsix を。

ちなみに、たまに「(新しく .vsix をつくりなおして再インストールしても)反映されないやんけ」なんてことが起きたりするので、(特に何度も再インストールを繰り返す場合は) VSCode を再起動した方が良い。 「なんか反映されてないな」と思ったら、とりあえず再起動。これ大事


MarketPlace に公開する

Azure アカウントが必要らしい。試してないのでわからん。


何をつくるべきか ~Language Support と Color Theme と Extension の違い~

ややこしいのでこれらの違いは押さえておきたい。


Language Support

言語定義。

「XXXX 言語は拡張子が .xxx で、文法はこんな感じで、……」みたいなことを定義する。文法を定義するとは、トークンとスコープを定義すること。

トークンとは「特定の意味を持つ文字列パターン」。正規表現で書く。

スコープとはトークンにつける名前。xxx.yyy.zzz みたいに階層的に書く。詳細は後述するが、スコープには命名規則(慣習)があって、この慣習に従わないとハイライトされない(VSCode のテーマはこの慣習スコープ名を元につくられているため)。

参考:


Color Theme

テーマ。

「このスコープはこの色とスタイルで表示します」みたいなことを定義する。対応しているのは foreground(文字色)、background(背景色)、fontStyle(bold italic underline の三種類のみ)。

一つ厄介なのは、テーマが VSCode 全体に波及する設定である ということ。もっと言えば、こんな事情がある。


  • テーマは VSCode 全体に波及する設定なので、好き勝手にスコープを使われると困る

  • なので VSCode では 慣習 に従ってスコープ名を付けてね、としている

  • その結果、テーマ作成者は 慣習に従ったスコープに対する定義だけつくれば良くなる

で、その慣習というのが TextMate で使われている Naming Conventions というやつ(12.4 項までスクロールしてください)。いくつか例をあげると、


  • コメントは comment.line とか comment.block を使え

  • 定数は constant.numeric とか constant.character とか使え

  • ……

こんな感じになっている。

参考:


Extension

拡張機能。

「このショートカットキーでこんな操作を呼び出す」とか「このメニューにこんな項目を追加したら、選択したらこんな操作をする」のように「呼び出し方」と「操作」を作り込むイメージ。

「呼び出し方」については、package.json に json で定義をゴリゴリ書いていく。

「操作」は、ガチでプログラミングする部分。VSCode API を駆使する。たとえば「こんなフォーマットの文字列を、現在のカーソル位置に挿入したい」操作がしたい場合は、以下のような感じになる。


  • 現在のカーソル位置を取得(window.activeTextEditor.selection.active)

  • 挿入したい文字列をつくる

  • 現在のエディタに対して edit を実行(window.activeTextEditor.edit)

「操作」はコマンドという単位でつくる。平たく言えば、こんな感じ。

    let completion_simple = vscode.commands.registerCommand('tritask.task.add', () => {

addTask();
});

これは tritask.task.add というコマンドを登録している。処理は addTask() 関数。この中で VSCode API を呼び出してタスク追加的な処理を実現する感じ。

で、「呼び出し方」は、この tritask.task.add に対して定義する。package.json にこんな感じで書く。

    ...

"menus": {
"commandPalette": [
{
"command": "tritask.task.add",
"when": "resourceExtname == .trita" // ★拡張子 .trita ファイルの時のみコマンドパレットに表示する
},
...
"keybindings": [
{
"command": "tritask.task.add",
"key": "alt+a", // Windows では Alt + A で呼び出せるようにする
"mac": "alt+a", // Mac では Option + A で呼び出せるようにする
"when": "resourceExtname == .trita" // ★このショートカットキーは .trita ファイルでのみ有効
},
...


Q: .xxx という拡張子で独自文法をサポートしたい

→ Language Support をつくりましょう。

ただしスコープ名は慣習に従いましょう。そうしないと既存のテーマで色付けされません。


Q: Language Support で独自のスコープ名を使いたい

→ 方法は二つあります。

一つ目は、独自スコープ名に対する色を定義したテーマ(Color Theme)を自作することです。

ただし、そのようなテーマを有効にすると、(あなたのテーマには慣習のスコープ名に対する定義がないので)大部分の既存のシンタックスハイライトがカラーリングされなくなります。たとえば json も Markdown も、Javascript も TypeScript も、ハイライトがすべて消えるでしょう。なぜなら、これらの言語定義は慣習のスコープ名に則っているからです。これを防ぎたいなら、あなたのテーマに「慣習のスコープ名に対する色の定義」をつくらねばなりません。

二つ目は、独自スコープ名に対する色の定義を 利用者の settings.json に書かせる 方法です。冒頭で紹介した Tritask は、この方法を用いています。


Q: 文字列加工やカーソル移動など便利操作を拡張機能としてつくりたい

→ Extension をつくりましょう。

VS Code API と戦うことになります。VS Code API がサポートしてないことは当然ながら実現できません。

ただし、拡張機能の開発プラットフォームは node.js ですから、npm で各種ライブラリを使えば割と色々なことができます。たとえば Child Process を使えば任意のコマンドラインを実行させることができます。


Q: .xxx という独自拡張子の独自文法に、独自操作を追加したい

→ Language Support と Extension をつくりましょう。

プロジェクトは二つになります。


開発中にお世話になるドキュメントやソース

基本的に Extension API | Visual Studio Code Extension API この公式ドキュメントを読み込めば OK。

あとは VSCode のソース vscode/extensions at master · microsoft/vscode も例が豊富なので参考になる。私は個人的に馴染みのある Markdown、Bat(バッチファイル)、Python あたりをよく読んだ。

それから特にトークンやらスコープ名やら書く時に Language Grammars — TextMate 1.x Manual もお世話になる。

個人的には以下もよく見た。


Language Support 開発メモ

今回私は .trita ファイルをハイライトする Language Support をつくった。tmLanguage.json の書き方を簡単にまとめておく。

※ただし一般的なプログラミング言語ほどガチの定義ではなく、あくまでオレオレデータファイルを簡単に定義しただけなので注意。それでもこの例を見れば「オレオレ文法はこうやれば作れそう」ってのがなんとなくわかると思う。


syntaxes\trita.tmLanguage.json の書き方(全体構造)

以下のようになっている。

{

"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Tritask",
"scopeName": "text.trita",
"patterns": [
{
"include": "#line-with-separator"
},
...
],
"repository": {
"line-with-separator": {
"patterns": [{
"name": "line.separator.trita",
"match": "^(.+)(\\-{2,})(.+)$"
}]
},
...
}
}

メインは .patterns と .repository。

.patterns では「こんなトークンが登場しますよ」の定義を列挙する。

.repository では各トークンのマッチ条件と名前(スコープ名)を列挙する。


syntaxes\trita.tmLanguage.json の書き方(トークン定義 その1)

たとえばこれは、

        "line-with-separator": {

"patterns": [{
"name": "line.separator.trita",
"match": "^(.+)(\\-{2,})(.+)$"
}]
},

以下のような意味になる。


  • トークン名:line-with-separator

  • このトークンとみなされる文字列パターン: "^(.+)(\\-{2,})(.+)$"


    • 意味: - が二つ以上並んだ行



  • このトークンのスコープ名: line.separator.trita

ちなみに色の定義はない。

色の定義はテーマ側で行うことになる。この場合、line.separator.trita に対する色はこれだ!という定義をテーマ側で行うことになる。もちろん、この line.separator.trita というスコープ名は私が書いたオレオレスコープ名であり、慣習に従っていないので、既存のテーマでは色付けされない。


syntaxes\trita.tmLanguage.json の書き方(トークン定義 その2)

もう少し複雑な例を。

        "time": {

"patterns": [{
"match": "\\b([0-9]{2}:[0-9]{2}) ([0-9]{2}:[0-9]{2})\\b",
"captures" : {
"1" : {
"name" : "time.start.trita"
},
"2" : {
"name" : "time.end.trita"
}
}
}]
},

これは以下のような意味になる。

まず、


  • トークン名:time

  • このトークンとみなされる文字列パターン: "\\b([0-9]{2}:[0-9]{2}) ([0-9]{2}:[0-9]{2})\\b"


    • 意味: hh:mm hh:mm という文字列

    • ちなみに \\b は単語区切りの意



となっているが、このトークンのスコープ名がちょっと複雑になっている。それが captures の部分で、


  • 1 の部分は time.start.trita というスコープ名に

  • 2 の部分は time.end.trita というスコープ名に

という意味になっている。キャプチャについては正規表現を勉強していただきたいが、一言で言えば () で囲った部分を指す概念で、順に 1, 2, 3, ... と番号が振られる。

captures を使えば「time には time start と time end がある」みたいな階層的な文法を簡単に定義できる。


Extension 開発メモ

今回私は .trita ファイルに対する操作体系を Extension で作り込んだ。結果として、Alt + A でタスク行を挿入したり、Alt + C で複製したり、Alt + Z で並び替えしたり、といったことができるようになった。

この節では Extension 開発時に書いたソースに関する、いくつかの解説を行う。

※言語は TypeScript を想定する。


エントリーポイント

いわゆる main 関数にあたる部分。yo code した時にちゃんと定義されているが、一応見てみる。

export function activate(context: vscode.ExtensionContext) {

let task_add = vscode.commands.registerCommand('tritask.task.add', () => {
addTask();
});
...

context.subscriptions.push(
task_add,
...
);
}

やることは単純で、activate() 関数内でコマンド登録と自動解放登録をする。

コマンド登録は vscode.commands.registerCommand()。パラメータとしてコマンド名 xxx.yyy 形式の文字列と、処理の中身を関数で与える。

自動解放登録はregisterCommand() の戻り値を context.subscriptions.push() にセットしていけば良い。いわゆるデストラクションの登録である。


何よりもまずは「今開いているエディター」を取得する

拡張機能でやることと言えば「エディタ上の内容を編集する」「カーソルを動かす」が二大メインとなるだろう。いずれにせよ、「今開いているエディター」を操作するためのオブジェクトを手に入れる必要がある。vscode.window.activeTextEditor がこれに相当する。

関数化しておくと楽だと思う。

function getEditor(){

let editor = vscode.window.activeTextEditor;
if(editor == null){
abort("activeTextEditor is null currently.")
throw new Error();
}
return editor;
}

リファレンスはこの辺。


カーソルを動かす(内蔵コマンド)

動かし方は二種類ある。

一つ目は、VSCode 内蔵コマンドを呼び出すこと。

以下は golinetop(行頭に移動)、golineend(行末に移動)、left(←)、up(↑) を定義している。

class CursorMover {

static golinetop() {
vscode.commands.executeCommand("cursorLineStart");
return this;
}

static golineend() {
vscode.commands.executeCommand("cursorLineEnd");
return this;
}

static left() {
vscode.commands.executeCommand("cursorLeft");
return this;
}

static up() {
vscode.commands.executeCommand("cursorUp");
return this;
}
}

内蔵コマンドについては File > Preferences > Keyboard Shortcuts から探すと良い。たとえば「cursor」で検索してみると、cursor に関するコマンドが多数ヒットする。まあコマンド名だけ見てもわかりづらかったりするのだが。


カーソルを動かす(絶対指定)

内蔵コマンドでできそうにないなら、自力で頑張るしかない。

まずカーソル位置を変えるには vscode.window.activeTextEditor.selection に(移動先の位置を定義した)Selection 型のオブジェクトを代入すれば良い。

let editor = getEditor();

editor.selection = (ここにSelection型オブジェクトを指定)

では Selection オブジェクトはどうやってつくるのか。vscode-api#Selection を見ていただきたいが、Position 型を二つ指定すれば良い。ややこしいが、Position 型は「ある一つの位置」を表すのに対し、Selection は「ある一つの範囲選択」を表す。Selection には「範囲選択の開始位置」と「終了位置」の二つが必要なので、Position 型を二つ指定している。

では Position 型はどうつくるか。vscode-api#Position を見ていただきたいが、line と character から成る。


  • line: n行目

  • character: (line行目の)character文字目

となっている。たとえば Position(3, 20) なら 3 行目の 20 文字目だ。ただし 0 オリジンなので実際は 4 行目の 21 文字目である。

ともあれ、これで準備が整った。カーソルを4 行目の 21 文字目に動かす例を見てみよう。

let editor = getEditor();

let destPos = new Position(3, 20);
let destSel = new Selection(destPos, destPos); // 範囲選択しないなら同じ Position を指定すれば良い
editor.selection = destSel;


カーソルを動かす(相対指定)

カーソルを 今のカーソル位置から相対的に動かしたい ニーズもある。可能だ。

要は「今のカーソル位置を示す Position」を取得した後、「その値のベースにして定義した相対位置」を指定した Position を作れば良い。

以下のようになる。

// 現在位置の行頭を表す Position.

let editor = getEditor();
let curPos = editor.selection.active;
var newY = curPos.line; // 行番号は変えない
var newX = 0; // 何文字目かを変える。行頭は 0 文字目なので 0。
let newPosWithRelative = curPos.with(newY, newX);

editor.selection.active で今のカーソル位置を取得する。Position.with() で当該 Position オブジェクトの複製(clone)をつくる。


内容を編集する

手順は意外とシンプルで、以下のようになる。

let editor = getEditor();

let f = function(editBuilder: vscode.TextEditorEdit): void{
editBuilder.insert(挿入先の位置をPosition, 挿入したい文字列をstring);
}
editor.edit(f);

vscode-api#TextEditor の METHOD の項にある edit() を見ていただきたい。「こういう編集をしてください」というコールバック関数を与えるようになっている。

で、その編集方法を指定する作法が vscode-api#TextEditorEdit。メソッドは四種類用意されている。


  • insert: 指定位置に指定文字列を挿入

  • delete: 指定範囲を削除

  • replace: 指定範囲を指定文字列に置換

  • setEndOfLine: 改行コードを指定コード(CRLF か LF のどちらかのみ指定可)に変換

ラクチンだ。


Q: editor.edit() した後、さらに処理したい場合はどうすれば?

ここで一つハマることがある。

editor.edit() 後に書いた処理(カーソル移動や別の TextEditorEdit 処理)がなぜか効かない という現象だ。

たとえば以下は正しく動作しないだろう。

let editor = getEditor();

// 何か挿入して、
let f = function(editBuilder: vscode.TextEditorEdit): void{
editBuilder.insert(挿入先の位置をPosition, 挿入したい文字列をstring);
}
editor.edit(f);
// その後で削除して、
f = function(editBuilder: vscode.TextEditorEdit): void{
editBuilder.delete(削除位置をSelection);
}
editor.edit(f);
// 最後にカーソルを動かす
editor.selection = (移動先のSelection)

おそらく最初の insert しか効かないはず。

なぜこんなことが起こるかというと、edit() が非同期的なメソッドだからだ。つまり edit() の次に書かれたコード = edit() 実行完了後に実行されるはずのコード、という等式は 成立しない

「edit() 実行完了後」をきちんと知るためには、さらにお作法が必要となる。それが Thenable 型である。

結論を言うと、こんな感じで書けば良い。

let editor = getEditor();

let f = function(editBuilder: vscode.TextEditorEdit): void{
editBuilder.insert(挿入先の位置をPosition, 挿入したい文字列をstring);
}
let thenAfterInsert = editor.edit(f);
thenAfterInsert.then(
(isSucceededEditing) => {
if(!isSucceededEditing){
return; // なんか失敗してるのでとりあえず中止しとく.
}
// ★ここが insert 完了後に実行される場所 ★
}
)

Thenable は promise という「Javascript で非同期処理を楽に書く仕組み」をラップしたもので、then() の中に実行完了後の処理を書いてね というものだ(と思ってる。勉強不足で正直まだよくわかってない)。

※ちなみに処理を重ねたい場合は、ネストする必要はなく then(...).then(...) みたいにメソッドチェーンすればいいらしい。まだ試してないが。参考 → 【JavaScript入門】誰でも分かるPromiseの使い方とサンプル例まとめ!


Q: (余談) なんで edit() は Thenable とかいう面倒くさそうな仕組みになっている?

想像だが、edit() がすぐに終了するとは限らないからだと思う。

edit() をすると、内部的には VSCode が色んな処理を走らせている。処理が一瞬で終わる保証がない。もし、これを同期的な仕組みにしてしまうと「なんか処理が遅いんだけど」となってしまう。極論、edit() を 10 回実行するコードがあったとして、1 回に 0.05s かかるとしたら、ユーザー側は全部終わるのに 0.5 秒待たないといけない。ストレスだ。

func() // edit() を 10 回くらい使ってて 0.5s かかる

// ★1ここに来るまでに 0.5s 待つことになる

これが Thenable であれば、待つ必要はない。一方で「edit() の方は非同期で走らせてて、終わったら随時次の処理をするよー」という感じで、並行で走る。

func() // edit() を 10 回使っているが Thenable で実装されている

// ★2ここには一瞬で来る

つまり ★2 の位置に来た時、func() の処理が完了しているとは限らない が、少なくとも func() の処理が終わる 0.5s まで待たされることはない のである。

……まあ非同期という概念のお話ですね。私もまだまだ勉強不足なので追々勉強していきます。


ライブラリを使う

node.js ベースなので npm でインストール可能なライブラリが色々使える。

例1: 日付時刻操作を簡単に行う moment.js

var moment = require('moment');

moment.locale("ja");

class DateTime {
private _momentinst: any
private _format: string

public constructor(){
this._momentinst = moment();
this._format = 'YYYY/MM/DD';
}

public toString(){
return this._momentinst.format(this._format);
}
}

class DateTimeUtil {
static todayString(): string {
var dtobj = new DateTime();
return dtobj.toString();
}

static nowtimeString(): string {
return moment().format("HH:mm");
}
}

DateTimeUtil.todayString() を呼び出せば 2019/08/20 ← こんな文字列が取れるようになる

例2: コマンドラインを実行する child_process

const exec = require('child_process').exec;

function doSort(){
let commandLine = `python -i helper.py --sort`;

exec(commandLine, (err:any, stdout:any, stderr:any) => {
if(err){
console.log(err);
}
});
}

doSort() を実行すると python -i helper.py --sort が実行される。

……と、こんな具合で、ライブラリ次第で相当なことができる。他にも ファイル操作やクリップボード操作やらできるみたい


おわりに

だいぶ荒削りですが、Visual Studio Code で拡張機能を開発してみたい皆様の参考になりましたら幸いです。

色々拙いと思うのでツッコミ歓迎です!

p.s. package.json まわりの解説をもうちょっと増やしたいですが力尽きたので、気が向いたら……。ドキュメントは Extension Manifest この辺です。