試しに 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 は依存関係等で迷宮みたいに通信走らせているので、キツイ環境下だと何かしらひっかかってよくわからんエラーで上手く動かない率が高い。
最後に私が試行錯誤して見つけた情報源の一部を置いておく。
- ProxyResolver#getCertificates depends on maxBuffer length · Issue #72844 · microsoft/vscode
- [json] schema not loading behind working proxy everything else works fine · Issue #74991 · microsoft/vscode
- node.js - How to fix RequestError: Error: connect ETIMEDOUT while running Mocha JS/NodeJS test from behind proxy - Stack Overflow
- [Bug] Extension host did not start in 10 seconds, it might be stopped on the first line and needs a debugger to continue. · Issue #72607 · microsoft/vscode
- proxy - unable to get local issuer certificate vscode - Stack Overflow
- ……
開発手順
開発、テスト、デバッグ、公開までの流れや大まかな手順をざっくりと。
初回時
他のリポジトリから 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 のテーマはこの慣習スコープ名を元につくられているため)。
参考:
-
Syntax Highlight Guide | Visual Studio Code Extension API
- 読めるならちゃんと読む
- 結局は公式ガイド読んで手動かすのが一番確実
-
vscode/extensions/markdown-basics at master · microsoft/vscode
- VSCode にデフォで入っている Markdown の言語定義
- syntaxes/markdown.tmLanguage.json を見ると良い
Color Theme
テーマ。
「このスコープはこの色とスタイルで表示します」みたいなことを定義する。対応しているのは foreground(文字色)、background(背景色)、fontStyle(bold italic underline の三種類のみ)。
一つ厄介なのは、テーマが VSCode 全体に波及する設定である ということ。もっと言えば、こんな事情がある。
- テーマは VSCode 全体に波及する設定なので、好き勝手にスコープを使われると困る
- なので VSCode では 慣習 に従ってスコープ名を付けてね、としている
- その結果、テーマ作成者は 慣習に従ったスコープに対する定義だけつくれば良くなる
で、その慣習というのが TextMate で使われている Naming Conventions というやつ(12.4 項までスクロールしてください)。いくつか例をあげると、
- コメントは comment.line とか comment.block を使え
- 定数は constant.numeric とか constant.character とか使え
- ……
こんな感じになっている。
参考:
-
vscode/extensions/theme-monokai/themes at master · microsoft/vscode
- VSCode にデフォで入っている monokai テーマのソース
- themes/monokai-color-theme.json を見ると良い
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 もお世話になる。
個人的には以下もよく見た。
-
Syntax Highlight Guide | Visual Studio Code Extension API
- トークンとかスコープ名とか、その辺の基礎が書いてある
-
Visual Studio Code Key Bindings
- そもそもショートカットキーについてよくわかってなかったのでこれ読んで勉強した(WinとMacでどう違うか等)
-
VS Code API | Visual Studio Code Extension API
- エディタの内容取得、編集、カーソル移動など。
-
Contribution Points | Visual Studio Code Extension API
- packege.json の書き方
- menus で右クリックメニューに項目を追加するとか、keybindings でショートカットキーを定義するとか
- VSCode 拡張機能開発は割と packege.json 編集ゲーなところがある ので、しっかり読んで、試して把握しておくと楽できる
- ※気力尽きたので本記事では細かい解説はしません……
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 この辺です。