VSCodeの拡張機能を作ってみる。
VSCodeの拡張機能を作ってみます、内容はほとんどハローワールドです。
今回の拡張機能を作るにあたり、以下の記事を参考にさせていただきました。
作るもの
- cursorConvert
- カーソル位置にある単語を
${}
で囲む
- カーソル位置にある単語を
- selectConvert
- 選択中の単語を
${}
で囲む
- 選択中の単語を
- snippets
- 入力位置に
${$1}
のsnippetを挿入する
- 入力位置に
snippetsのほうはsnippetsの設定ファイルを作ればvscode単体でもすぐ作れるものです。
しかしconvertのほうは私にはやり方がわかりませんでした、なので拡張機能を作って対応しようと思います。
雛形のダウンロード
$ npm install -g yo generator-code
$ yo code
先ほどの記事を参考にポチポチと質問に答えていきます、1年ほど経っていますが内容は変わっていませんでした。
package.managerの選択では npm
yarn
npnm
が提示されました。
試しにnpnmを選択したところインストールが完了しました
しかし、ビルド時にエラーが出たのでやめました!
npmを使ってインストールします。
src/extension.ts を書いていく
extension.tsにはコマンドとして呼ばれたときの処理を書いていきます。
forTemplateLiteral.select
//selectConvert
let disposableSelect = vscode.commands.registerCommand(
"forTemplateLiteral.select",
async () => {
const editor = vscode.window.activeTextEditor;
if (editor) {
const selections = editor.selections;
for (const selection of selections) {
const text = editor.document.getText(selection); //実行時の選択テキスト
const newText = `\${${text}}`;
// 元々選択してあるテキストを置き換える
await editor.edit((editBuilder) => {
editBuilder.replace(selection, newText);
});
}
await vscode.commands.executeCommand("cursorRight");
}
}
);
context.subscriptions.push(disposableSelect);
こちらのConvertは、文字列を選択した状態からコマンドが実行されます。
このテキストの置き換えを実行後は、置き換えたテキスト全体が選択された状態になります。
少し使いづらいので、cursorRight
を発生させ、置き換えたテキストの右端にカーソル位置を移動した状態で処理を終了します。
意図しない動作になる可能性もあるので、cursorRightは無いほうがいいかもしれません、
注意点としては、editor.edit()
はawaitをつけて実行します。
forTemplateLiteral.cursor
//cursorConvert
let disposableCursor = vscode.commands.registerCommand(
"forTemplateLiteral.cursor",
async () => {
//選択状態を解除する
await vscode.commands.executeCommand("cancelSelection");
//カーソル位置の単語を選択する
await vscode.commands.executeCommand(
"editor.action.addSelectionToNextFindMatch"
);
//選択した単語を変換する
await vscode.commands.executeCommand("forTemplateLiteral.select");
//
//todo: restore original selections
// 多分, 難しいのでやらない
//
}
);
context.subscriptions.push(disposableCursor);
カーソル位置の単語を選択するコマンドが用意されているので、それを実行してからforTemplateLiteral.select
を実行します。
実行前の選択状態を復元できるといいなと思いましたが、大変そうなのでやめました
forTemplateLiteral.snippets
//snippets
let disposableSnippets = vscode.commands.registerCommand(
"forTemplateLiteral.snippets",
() => {
const editor = vscode.window.activeTextEditor;
if (editor) {
editor.insertSnippet(
new vscode.SnippetString("${$1}"),
editor.selection.active
);
}
}
);
context.subscriptions.push(disposableSnippets);
こちらはvscode.SnippetStringを使って実行位置でsnippetsを起動します。
snippetsはvscodeのuser-snippetsと同様に操作できます、今回は$1
の場所から始まり、入力後にタブキーを押すとコードの右端でsnippetが終了します。
package.jsonにてコマンド登録
"activationEvents": [],
"main": "./dist/extension.js",
"contributes": {
"commands": [
{
"command": "forTemplateLiteral.cursor",
"title": "Template Literal",
"category": "Cursor",
"when": "editorTextFocus"
},
{
"command": "forTemplateLiteral.select",
"title": "Template Literal",
"category": "Select",
"when": "editorTextFocus"
},
{
"command": "forTemplateLiteral.snippets",
"title": "Template Literal",
"category": "Snippets",
"when": "editorTextFocus"
}
]
},
今回の場合、activationEventsは[]
でいいようです。
title
やcategory
を設定すると、VSCodeでは以下のように表示されます。
ビルドとインストール
参考にした記事から何も変わっていないので割愛します。
npnmを使った場合 $ npx vsce package
でコケたのでnpmかyarnが簡単だと思います。
テスト
$ yo code
ではテストに関するパッケージやファイルの設定も一通り済ませた状態になってます。
今回はあらかじめ作られているsrc/test/suite/extension.test.ts
を変更して、今回作った拡張機能に関してのテストを実行します。
今回はじめてテストプログラムを書きました。
いきなりわからない(未解決)
VSCodeの左のデバッグタブから、対象を Extension Tests
に変更し実行します。
しかしThere are issues with task "npm: watch-tests". See the output for more details.
とポップアップが表示され実行できません。
Error: Invalid problemMatcher reference: $tsc-watch
というエラーが出ています。
tasks.json
を確認すると $tsc-watch
を指定している部分でエラーメッセージの表示があります。
Unrecognized problem matcher. Is the extension that contributes this problem matcher installed?(1)
解決できずに諦めました、デバッグタブからのテスト実行はできていません。
終わりだ…。
npm test
でテストを直接実行する
Run and Debug経由のテストは諦めましたが、私にはconsole.log()
があるのでそれで頑張ってテストしたいと思います。
$ npm test
でテストを実行します
breakpointを設定したり、watchしたりはできません。
extension.test.tsの内容
import * as assert from "assert";
import * as vscode from "vscode";
suite("Extension Test Suite", () => {
// テストが開始されたことをユーザーに知らせる情報メッセージを表示する
const publisher = "undefined_publisher"; //package.jsonのpublisher 無い場合は "undefined_publisher" 固定文字列が使用される
const packageName = "fortemplateliteral"; //package.jsonのname nameは小文字限定,
const extensionId = `${publisher}.${packageName}`; //publisher + "." + name 大文字小文字は区別しないようだ
const commands = [
"forTemplateLiteral.cursor",
"forTemplateLiteral.select",
"forTemplateLiteral.snippets",
];
vscode.window.showInformationMessage("テストを開始します。");
//拡張機能がインストールされているか
test("Extension should be present", () => {
assert.ok(vscode.extensions.getExtension(extensionId));
});
//拡張機能がアクティブにできるか
test("Should activate the extension", async function () {
await vscode.extensions.getExtension(extensionId)!.activate();
assert.ok(true);
});
//拡張機能の各コマンドが実行できるか
commands.forEach((command) => {
test(`Should execute the command ${command} `, async function () {
const result = await vscode.commands.executeCommand(command);
assert.strictEqual(result, undefined);
});
});
//cursorが実行できるか
test("convert command should convert cursor text", async function () {
//新しいエディタを設定
const newContent = "const name = Alice;";
const newDocument = await vscode.workspace.openTextDocument({
content: newContent,
language: "javascript",
});
await vscode.window.showTextDocument(newDocument);
//カーソル位置を作成して変更
const selection = new vscode.Selection(0, 13, 0, 13);
vscode.window.activeTextEditor!.selection = selection;
//コマンド実行
await vscode.commands.executeCommand("forTemplateLiteral.cursor");
//コマンド実行後のテキストを取得
const resultText = vscode.window.activeTextEditor?.document.getText();
assert.strictEqual(resultText, "const name = ${Alice};");
});
//selectが1つの選択に対し実行できるか
test("convert command should convert selected a word", async function () {
//新しいエディタを設定
const newContent = "const name = Alice;";
const newDocument = await vscode.workspace.openTextDocument({
content: newContent,
language: "javascript",
});
await vscode.window.showTextDocument(newDocument);
//選択状態を作成して変更
const selection = new vscode.Selection(0, 13, 0, 18);
vscode.window.activeTextEditor!.selection = selection;
//コマンド実行
await vscode.commands.executeCommand("forTemplateLiteral.select");
//コマンド実行後のテキストを取得
const resultText = vscode.window.activeTextEditor?.document.getText();
assert.strictEqual(resultText, "const name = ${Alice};");
});
//selectが複数の選択に対し実行できるか
test.only("convert command should convert selected multi word", async function () {
//新しいエディタを設定
const newContent = `const name = Alice;
const name1 = Alice;
const name12 = Alice;
const name13 = Alice;`;
const newDocument = await vscode.workspace.openTextDocument({
content: newContent,
language: "javascript",
});
await vscode.window.showTextDocument(newDocument);
//選択状態を作成して変更
const selections = [];
selections.push(new vscode.Selection(0, 13, 0, 18));
selections.push(new vscode.Selection(2, 15, 2, 20));
selections.push(new vscode.Selection(3, 15, 3, 20));
vscode.window.activeTextEditor!.selections = selections;
//console.log("選択中の単語");
//for (const selection of vscode.window.activeTextEditor!.selections) {
// console.log(vscode.window.activeTextEditor!.document.getText(selection));
//}
//コマンド実行
await vscode.commands.executeCommand("forTemplateLiteral.select");
//コマンド実行後のテキストを取得
const resultText = vscode.window.activeTextEditor?.document.getText();
assert.strictEqual(
resultText,
`const name = \${Alice};
const name1 = Alice;
const name12 = \${Alice};
const name13 = \${Alice};`
);
});
//snippetsが実行できるか
test("snippets command should insert snippet text", async () => {
//新しいエディタを設定
const newContent = "const name = ";
const newDocument = await vscode.workspace.openTextDocument({
content: newContent,
language: "javascript",
});
await vscode.window.showTextDocument(newDocument);
//選択状態を作成する
const selection = new vscode.Selection(0, 13, 0, 13);
vscode.window.activeTextEditor!.selection = selection;
//コマンド実行
await vscode.commands.executeCommand("forTemplateLiteral.snippets");
//エディタ上で入力を行う
await vscode.commands.executeCommand("type", { text: "Alice" });
//await vscode.commands.executeCommand("tab"); //これはタブキーが入力されるだけなので不可
await vscode.commands.executeCommand("jumpToNextSnippetPlaceholder"); //次のpladeholderに移動で終点に移動
await vscode.commands.executeCommand("type", { text: ";" });
//実行後のテキストを取得
const resultText = vscode.window.activeTextEditor?.document.getText();
assert.strictEqual(resultText, "const name = ${Alice};");
});
//テストに対するmochaのtimeoutを変えてみるテスト;
test.skip("check mocha timeout setting", async function () {
const sleep = (msec: number) =>
new Promise((resolve) => setTimeout(resolve, msec));
this.timeout(11000);
await sleep(10000);
});
});
extensionId
- package.jsonの
name
とpublisher
が使用されます。 - publisher.name がextensionIdになります
- package.jsonにpublisherを設定していない場合, "undefined_publisher"が使われます
- 今回は設定していないので "undefined_publisher.fortemplateliteral" がextensionIdになります。
その他
- 各テストのskip等
- test.skip()でスキップできます。
- test.only()でそのテストのみを実行します。
- mochaのテストにはデフォルトのtimeoutがある
- デフォルト設定を変えてもいいですが、this.timeout(10000)等で変更できます
- arrow functionで書くと
- thisでmochaの設定を変更できません、上記のコードではアロー関数で書かれているものもあります。thisを使わなければ問題ないので、違いを意識するか書き方を統一するかはその人次第だと思います。
"snippets command should insert snippet text",
この部分でsnippetsコマンドを実行してテストしています。コード中のコメントにある通りですが、vscode.commands.executeCommand("tab")
はタブキーの入力ではなくタブを挿入します。
snippetの移動はjumpToNextSnippetPlaceholder
を使用します。
snippetの動作完了は acceptSnippet
です。
実行してみる
$ npm test
で実行します、"check mocha timeout setting"のテストのskipは外しておきます。
Extension Test Suite
✔ Extension should be present
✔ Should activate the extension
✔ Should execute the command forTemplateLiteral.cursor
✔ Should execute the command forTemplateLiteral.select
✔ Should execute the command forTemplateLiteral.snippets
✔ convert command should convert cursor text (142ms)
✔ convert command should convert selected a word
✔ convert command should convert selected multi word (41ms)
✔ snippets command should insert snippet text (116ms)
✔ check mocha timeout setting (10003ms)
10 passing (10s)
終わりに
変化の早いパッケージだと数ヶ月でもガラリと変わったりするので不安でしたが、$ yo code
は参考記事ほぼそのままでビルドまで終わりました。
VScodeのextension作成は資料は少ないかもしれませんが、参考にしたものがそのまま使えるので良いですね。