Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

試しに 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 この辺です。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした