生まれて初めてVS Codeの拡張機能を作った話（環境構築からパッケージ化まで）

## はじめに
この記事は[NTTテクノクロス Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ntt-tx) : シリーズ1、13日目の記事です。
こんにちは、NTTテクノクロスの栗原です。
普段はOSSの調査・検証等の業務に携わっています。

今回は業務の効率化のため、VS Codeの拡張機能として、javaファイルおよびpropertiesファイル中のプロパティキーを参照しているメッセージやソースコードを検索して表示してくれる機能を作ってみました。
VS Codeの拡張機能を作るのは初めてだったので、その過程で調べたことをまとめてみました。

### 背景

この拡張機能はApache Tomcat関連の業務に携わっているチームメンバへ向けて作りました。Apache Tomcatでは、出力するログメッセージを、プロパティ名と対応するメッセージをキーと値のペアとして、言語ごとのpropertiesファイルで管理しています。ソースコード（javaファイル）中では、どのメッセージを出力するのかをプロパティキーで指定します。
これにより、動的な言語の変更を実現しているわけですが、ソースコードを一瞥しただけではメッセージ本文まで分かりません。それに本文を確認しようと思ったら『参照しているpropertiesファイルを見つけたうえで、プロパティキーでファイル内を検索する』という手順を踏まねばなりません。
ソースコードを確認する中で、この作業はそこそこ面倒...ということで作ってみました。

### 作ったもの

作った機能を簡単に紹介します。
まずjavaファイルを開いた状態では、カーソルが指しているソースコード中のプロパティキーに対応しているメッセージ本文（英語＋指定した言語）をホバー表示します。
また`[Peek...]`をクリックすると、各propertiesファイル上の当該のプロパティキーを定義している箇所をすべて、ウィンドウ内に表示します。`[Search...]`をクリックすると、すべてのpropertiesファイルから、当該のプロパティキーを検索します。

![peek_search_properties.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939972/df87b6c6-0321-81f1-9322-012706aaa39f.gif)

次にpropertiesファイル上では、カーソルが指しているプロパティキーを参照しているコードをホバー表示します。
また`[Peek...]`をクリックすると、各javaファイル上の当該のプロパティキーを参照している箇所をすべて、ウィンドウ内に表示します。

![peek_java.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939972/2b93bf81-ca24-2cf9-dcf6-b12bc2bfa3cd.gif)

## 準備

### 環境
- Node.js: 20.16.0
- npm: 10.8.1
- VS Code: 1.95.3

### プロジェクトの作成
:::note
VS Codeの拡張機能の開発にはNode.jsが必要です。ここからは既にインストールされているものとして話を進めます。
:::

拡張機能を作るにあたり、まずは各種ひな型ファイルを含むプロジェクトの作成をしていきます。

以下のコマンドでYeomanとVS Code Extension Generatorをインストールしましょう。
```shell
npm install -g yo generator-code
```
インストールできたら、以下のコマンドを実行します。
```shell
yo code
```
コマンドを実行するといくつか質問されます。今回はTypeScriptで実装するので以下のように入力しました。
```shell
? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? HelloWorld
? What's the identifier of your extension? helloworld
? What's the description of your extension? Hello World
? Initialize a git repository? No
? Which bundler to use? webpack
? Which package manager to use? npm
```
:::note warn
`? Initialize a git repository?`に対して`Yes`と答える場合は事前にgitのインストールが必要になるので注意してください。
:::
質問に答え終わると、プロジェクトの作成が始まります。

## サンプルの確認

### デバッグ実行

新規作成したプロジェクトにはサンプルコードが記載されています。動作確認がてらデバッグ実行してみます。
**F5**キーを押すことでデバッグが開始され、新しくウィンドウが開きます。このウィンドウ上で実装中の拡張機能の動作確認ができます。
開いたウィンドウ上で、**Ctrl + Shift + P**でコマンドパレットを開き、`Hello World`と入力して**Enter**を押し、コマンドを実行してみましょう。正常にプロジェクトが作成できていれば、以下のように画面右下にメッセージが表示されます。

![スクリーンショット 2024-11-21 115140-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939972/fef6269c-0ee7-7afe-525e-242708b2c633.png)

### サンプルコードの確認

動作確認ができたので、次はサンプルコードを実際に見てみましょう。
サンプルコードは`src`ディレクトリにある`extension.ts`に書かれています。

```typescript:src/extension.ts
import * as vscode from 'vscode';

// activate()は拡張機能が有効になったとき、一度だけ呼ばれる関数
// この中にコマンドの登録（後述）など諸々の処理を書いていく。
export function activate(context: vscode.ExtensionContext) {

	console.log('Congratulations, your extension "helloworld" is now active!');

    // registerCommand()は第一引数にコマンド名、第二引数にコマンドの処理内容を渡してコマンドを登録する関数
    // ここではhelloworld.helloWorldという名前で、実行されると「Hello World from HelloWorld!」という
    // インフォメーションメッセージを表示するコマンドを登録している。
	const disposable = vscode.commands.registerCommand('helloworld.helloWorld', () => {
		vscode.window.showInformationMessage('Hello World from HelloWorld!');
	});

    // context.subscriptions.push()はこの拡張機能が無効化される際に、VS Codeから取得したリソースを解放されるようにするため処理
    // 各種機能の登録と合わせて書いておく。
	context.subscriptions.push(disposable);
}

// deactivate()は拡張機能が無効になったとき、一度だけ呼ばれる関数
// 拡張機能が無効化されるときに何か処理させたい場合はこの中に書く。
export function deactivate() {}
```

## 拡張機能の開発

### 拡張機能が有効化されるタイミングの設定

いつ拡張機能が有効化されるかは`package.json`内の`activationEvents`属性で設定できます。
例えば、[今回作った拡張機能](#作ったもの)では、javaあるいはpropertiesファイルが開いたときに有効化されるようにしたいので、以下のように設定しています。
```json:package.json
"activationEvents": [
    "onLanguage:java", 
    "onLanguage:properties"
]
```
他にも`"onCommand:<コマンド名>"`を指定することで、コマンド実行時に有効化されるよう設定できますが、VS Code 1.74.0以降では特にこの設定をせずとも、コマンド実行時に自動で有効化されるようです。[^1]

[^1]: 参考: [Activation Events | Visual Studio Code Extension API](https://code.visualstudio.com/api/references/activation-events)

### マウスホバーしたときにメッセージを表示する

VS Code上でコードを書いているときによく見る、カーソルを合わせると関数や型の説明を表示してくれるあの機能です。
今回作った拡張機能の中では、以下の機能を作る際に使用しています。
> まずjavaファイルを開いた状態では、カーソルが指しているソースコード中のプロパティキーに対応しているメッセージ本文（英語＋指定した言語）をホバー表示します。

ここからは、「カーソルが、typescriptファイル上の英数字列を指している場合にのみ、ホバー表示する」という処理を実装してみましょう。`activate()`関数の中を以下のように書き換えます。


```typescript:src/extension.ts
export function activate(context: vscode.ExtensionContext) {
	context.subscriptions.push(
        // registerHoverProvider()は、第一引数に対象とするファイルの種類を指定します。
        // これにより特定のファイルでのみメッセージを表示させることができます。
		vscode.languages.registerHoverProvider('typescript', {
            // provideHover()の中に、どのようなメッセージをホバー表示するのかを書いていきます。
			provideHover(document, position, token) {
                // 以下のように、引数である`document`(現在開いているドキュメント)、`position`(カーソルの位置) を使用することで、
                // カーソルの位置にある、正規表現にマッチする文字列の範囲を取得します。
				const range = document.getWordRangeAtPosition(position, /[a-zA-Z0-9]+/);
                // 正規表現にマッチする文字列が見つかった場合
				if (typeof range !== 'undefined') {
					return new Promise((resolve) => {
                        // 取得した範囲にある文字列を取得します。
						const key: string = document.getText(range);
                        // 取得した文字列（key）をマークダウン文字列として定義
                        let message: vscode.MarkdownString = new vscode.MarkdownString(key);
                        // vscode.Hover()をreturnすることで、渡した文字列（message）がホバー表示されます。
						return resolve(new vscode.Hover(message));
					});
				}
			}
		})
	);
}
```
実際に動かすと以下のようにメッセージを表示してくれます。

![スクリーンショット 2024-11-21 150653.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939972/eead5aa3-cf86-3b28-74c5-bd48968dc89a.png)

ここでは、`registerHoverProvider()`は、第一引数に対象とするファイルの種類として、`typescript`を指定していますが、ワイルドカードを使用したファイルの指定など複数の条件を組み合わせることもできます。[^2]

[^2]: 参考: [Document Selectors | Visual Studio Code Extension API](https://code.visualstudio.com/api/references/document-selector)

### コマンドをリンクとして埋め込む

クリック時に特定のコマンドを実行するリンクをコマンドURIと呼びます。これを前述のホバーメッセージなどに埋め込むことでボタンのような機能を持たせることができます。
今回作った拡張機能の中では、以下の機能を作る際に使用しています。
> `[Search...]`をクリックすると、すべてのpropertiesファイルから、当該のプロパティキーを検索します。

上記は、`workbench.action.findInFiles`というワークスペース配下のファイルに対して全文検索を行うコマンドを、リンクとして埋め込むことで実装しています。

ここからは、先ほど実装したホバー表示に、この`workbench.action.findInFiles`コマンドをURIとして埋め込んで、「カーソルが指している英数字列をキーワードに、対象ファイルから全文検索を行う」機能を追加してみましょう。`activate()`関数に下記の処理を追記します。

```diff_typescript:src/extension.ts
context.subscriptions.push(
		vscode.languages.registerHoverProvider('typescript', {
			provideHover(document, position, token) {
				const range = document.getWordRangeAtPosition(position, /[a-zA-Z0-9]+/);
				if (typeof range !== 'undefined') {
					return new Promise((resolve) => {
						const key: string = document.getText(range);
 						let message: vscode.MarkdownString = new vscode.MarkdownString(key);
+ 						// MarkdownStringを使う場合は、isTrustedを`true`にしないとコマンドURIが無効化されてしまいます。
+ 						// デフォルトでfalseになっているので、trueを指定します。
+						message.isTrusted = true; // コマンドURIの有効化
+						// コマンドURIは`command:<コマンド名>`という形式になっていて、引数を指定したい場合はURIエンコードされたjson配列として渡します。
+						// 今回はworkbench.action.findInFilesを<コマンド名>に指定します。
+						const searchCommandUri = vscode.Uri.parse(
+							`command:workbench.action.findInFiles?${encodeURIComponent(JSON.stringify({
+								query: key,              // 検索する文字列
+								triggerSearch: true,     // 詳細は分かっていませんが、現状trueにしないと検索を実行してくれないようです。
+								matchWholeWord: true,    // 完全一致する単語のみを検索する
+								isCaseSensitive: true,   // 大文字と小文字を区別する
+								filesToInclude: +'*.ts', // 検索対象のファイル（今回はワークスペース配下のtypescriptファイルに対してのみ検索）
+							}))}`
+						);
+						// マークダウン形式で、上記のリンクを挿入する。
+						message.appendMarkdown(`[Search word](${searchCommandUri})`);
						return resolve(new vscode.Hover(message));
					});
				}
			}
		})
	);
```

このコードを実際に実行してみると以下のようになります。
**Search word**というリンクをクリックすると、ファイルの全文検索が実行されます。

![vscode_commanduri2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939972/8d997e2d-0c1e-cc59-f200-2aff4558936d.gif)

ここでは`workbench.action.findInFiles`というVS Codeのビルトインコマンドを使用しましたが、他にも様々なコマンドが用意されています。[^3]

[^3]: ビルトインコマンド一覧: [Document Selectors | Built-in Commands | Visual Studio Code Extension API](https://code.visualstudio.com/api/references/commands)

## 拡張機能の公開

### パッケージ化

作成した拡張機能を共有できるようにVSIX形式にパッケージ化してみます。
まずVS Codeの拡張機能のパッケージ化および管理するためのツールであるvsceをインストールします。
```shell
npm install -g @vscode/vsce
```
インストールできたら、パッケージ化したいプロジェクトに移動して、以下のコマンドを実行します。
```shell
vsce package
```
実行後、`vsix`形式のファイルが作成されます。

### 拡張機能のインストール

「拡張機能」アイコン => 「...」アイコン => 「VSIXからインストール」をクリックしてパッケージを選択することでインストールできます。


![画像1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939972/a3360478-0e6b-4110-c218-aebc166e9111.png)


## おわりに

今回はTypeScriptも触ったことがない中で、VS Codeの拡張機能を1から作ったのですが、非常に良い経験になりました。VS Code APIでは今回紹介したもの以外にもたくさんの機能が提供されています[^4]。中には言語モデルとの対話機能を実装できるLanguage Model APIというものあるようです。
今回紹介した通り結構簡単に実装できるので、興味のある方は一度拡張機能を自作してみてはいかがでしょうか！

NTTテクノクロス Advent Calendar 2024：シリーズ1、明日は @rex02 さんの記事です！ぜひご覧ください！

[^4]: 参考: [Extension Guides | Visual Studio Code Extension API](https://code.visualstudio.com/api/extension-guides/overview)