簡単なVSCode Extensionを作る

# VSCodeの拡張機能を作ってみる。

VSCodeの拡張機能を作ってみます、内容はほとんどハローワールドです。
今回の拡張機能を作るにあたり、以下の記事を参考にさせていただきました。

https://qiita.com/HelloRusk/items/073b58c1605de224e67e


## 作るもの
- cursorConvert
    - カーソル位置にある単語を `${}` で囲む
- selectConvert
    - 選択中の単語を `${}` で囲む
- snippets
    - 入力位置に `${$1}` のsnippetを挿入する

snippetsのほうはsnippetsの設定ファイルを作ればvscode単体でもすぐ作れるものです。
しかしconvertのほうは私にはやり方がわかりませんでした、なので拡張機能を作って対応しようと思います。

## 雛形のダウンロード

`$ npm install -g yo generator-code`
`$ yo code`

先ほどの記事を参考にポチポチと質問に答えていきます、1年ほど経っていますが内容は変わっていませんでした。
package.managerの選択では `npm` `yarn` `npnm`が提示されました。

:::note alert
試しにnpnmを選択したところインストールが完了しました
しかし、ビルド時にエラーが出たのでやめました！
:::

npmを使ってインストールします。


## src/extension.ts を書いていく

extension.tsにはコマンドとして呼ばれたときの処理を書いていきます。


### forTemplateLiteral.select

```ts
  //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

```ts
  //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

```ts
  //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にてコマンド登録

```: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では以下のように表示されます。

![GreenShot20230220_150046-Window.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3154784/c5236c5d-c88d-eae5-6bbb-94209f7bbb56.png)




## ビルドとインストール

参考にした記事から何も変わっていないので割愛します。
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の内容

```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作成は資料は少ないかもしれませんが、参考にしたものがそのまま使えるので良いですね。