🎄

42headerを全てのソースに追加するVS Code拡張を作りました。

2021/12/18に公開

前書き

こんにちは。42tokyo Advent Calendar 2021の何日目も担当していない、42Tokyo在校生のynakamotです。
たまたま記事にできそうな話ができたので、書きます。

表題のとおり、ワークスペース内のすべてのC、ヘッダーファイルに42headerを追加する拡張機能を作った話です。
↓こんな感じ

開発の経緯

42生はコーディング規約で自分が書いたCファイル、ヘッダーファイルには既定のヘッダーを付ける必要があります。
もちろんvimやVSCodeでも拡張機能を使うことでワンアクションで挿入できるのですが、正直言って編集中につけるのは面倒です。(タイムスタンプの更新で、gitのログが汚れるのキライ)
かといって、全ての実装を終えた後にそれぞれのファイルに一つずつヘッダーを追加するのも面倒です。
先日、課題を一つ作り終えてヘッダーを追加しながらそんなことを強く感じ開発の着手に至りました。

実現したいこと

一つのコマンドで、開いているフォルダーのすべてのソースファイルに42headerを追加したい。
細かく言うと、
ワークスペース中にあるターゲットの拡張子を持ったファイルを全て取得し、
ファイル中に42headerがあるか確認して、
ない場合は設定 or 環境変数から生成されたheaderをファイルに追加して書き込みする。
をする拡張機能が欲しーーい!

VSCode Extension development ことはじめ

なにはともあれ、まずは公式のドキュメントから!
Your First Extension
ここに環境構築のやり方が載っているのでそれに沿っていきます。まずは、対話型でプロジェクトのひな型を生成するツールYeomanとVSCode拡張用のひな型をインストールします。
(あらかじめNode.jsとgitはインストールしといてね)

npm install -g yo generator-code

インストールできたら、yo codeでプロジェクトの生成が始まります。

❯ yo code

     _-----_     ╭──────────────────────────╮
    |       |    │   Welcome to the Visual  │
    |--(o)--|    │   Studio Code Extension  │
   `---------´   │        generator!        │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? 42 Header OneShot
? What's the identifier of your extension? 42-header-oneshot
? What's the description of your extension? add 42 Header to all sources!
? Initialize a git repository? Yes
? Bundle the source code with webpack? No
? Which package manager to use? yarn

最後に新しいウィンドウで開くか聞かれるのでお好みで開いてください。
じつはもうこの状態で拡張機能が動きます!
F5でデバッグ環境が新しいウインドウで開きます。
Ctrl+Shift+Pでコマンドパレットを開いて、hello worldを実行すると…

トースト通知が出ました!やったね!
実際の拡張機能の実装はsrcs/extension.tsにあります。

extension.ts
// すべてのコメントは意訳
// 拡張機能のapiライブラリ
import * as vscode from 'vscode';

//拡張機能が有効になるタイミングでこの関数が呼ばれるよ。
export function activate(context: vscode.ExtensionContext) {
	
	// console.log(or console.error)に出力できるよ。
	// ここは、拡張機能が有効化されるときにしか実行されないので、一回しか呼ばれないよ。
	console.log('Congratulations, your extension "42-header-oneshot" is now active!');

	// コマンドの名前は、package.jsonに定義されてる名前と同じにしてね。
	let disposable = vscode.commands.registerCommand('42-header-oneshot.helloWorld', () => {
		//この関数の中はコマンドが実行されるごとに実行されるよ。
		vscode.window.showInformationMessage('Hello World from 42 Header OneShot!');
	});

	// 拡張機能が無効化されるときに、コマンドが適切に登録解除されるようにする。
	context.subscriptions.push(disposable);
}

// 拡張機能が無効化されるときの処理、多くの場合特別な処理は必要なさそう。
export function deactivate() {}

コメントにあるとおり、コマンドはpackage.jsonに定義されています。

package.json
{
  "name": "42-header-oneshot",
	"displayName": "42 Header OneShot",
	"description": "add 42 Header to all sources!",
	"version": "0.0.1",
	"engines": {
		"vscode": "^1.63.0"
	},
	"categories": [
		"Other"
	],
	"activationEvents": [
        "onCommand:42-header-oneshot.helloWorld"
	],
	"main": "./out/extension.js",
	"contributes": {
		"commands": [
			{
				"command": "42-header-oneshot.helloWorld",
				"title": "Hello World"
			}
		]
	},
// ...
}

"activationEvents"と"contributes"の中にコマンド名がありました!
"activationEvents"は拡張機能が読み込まれるタイミングの設定です。onCommand:コマンド名でコマンドが実行されるときに拡張機能が有効化されます。今回作りたい拡張機能は頻繁に実行する必要はないのでこのままで良さそうです。
"contributes"は、VSCode内の機能を拡張するための設定を行う項目です。具体的には、拡張機能用の設定画面や、キーバインド、メニューの拡張などが設定できます。

package.json編集後
{
// ...
	"activationEvents": [
		"onCommand:42-header-oneshot.addHeader"
	],
	"main": "./dist/extension.js",
	"contributes": {
		"commands": [
			{
				"command": "42-header-oneshot.addHeader",
				"title": "Add 42 heder to all file in workspace"
			}
		],
		"configuration": {
			"title": "42 Header OneShot",
			"properties": {
				"42-header-oneshot.user": {
					"type": "string",
					"description": "User name override environment variable."
				},
				"42-header-oneshot.mail": {
					"type": "string",
					"default": null,
					"description": "Mail address override environment variable."
				}
			}
		},
		"keybindings":[
			{
				"command": "42-header-oneshot.addHeader",
				"key": "ctrl+alt+o",
				"mac": "cmd+alt+o"
			}
		]
	},
// ...
}

コマンドの実装

package.jsonの設定が終わったら、あとはコマンドの機能を実装していくだけです。
APIが充実しているので、公式のドキュメントや検索等を利用してやりたいことを見つける感じになりました。
今回の実装中に見つけたポイントを挙げていきます。

マルチワークスペース用の便利なAPIを使う

皆さんは今のVSCodeでワークスぺースとして複数のフォルダが開けるのはご存知ですよね?(僕は知らなかった😊)
マルチワークスペースと一緒に実装された便利なAPIをふたつご紹介です。参考URL

showWorkspaceFolderPick

ユーザーが複数のワークスペースを開いているときに、ワークスペースを選択するダイアログを表示し、返り値として選択されたワークスペースを返すメソッドです。下のような感じで使えます。

function getWorkspace(): Thenable<vscode.WorkspaceFolder | undefined> {
  if (!vscode.workspace.workspaceFolders) {
    vscode.window.showInformationMessage('please open workspace.');
    return Promise.reject(undefined);
  } else if (vscode.workspace.workspaceFolders.length === 1) {
    return Promise.resolve(vscode.workspace.workspaceFolders[0]);
  }
  // ワークスペースが一つじゃないとき選択してもらう
  return vscode.window.showWorkspaceFolderPick();
}

こんな感じでポップアップが出てきます。

RelativePattern

RelativePattern型を使うことでワークスペースのパスをbaseにしたglobパターンが作れます。今回はワークスペース内のパターンに一致するファイルを再帰的に検索する際に利用しました。

// ...
  // ワークスペースのターゲットの拡張子を持ったファイルのパターン
  const pattern = new vscode.RelativePattern(workspace, '**/*.{c,h,cpp,hpp}');
  // パターンに一致するファイルの情報が配列で取得できる!
  const uris = await vscode.workspace.findFiles(pattern, null);
// ...

Node.jsのfsは使わない。

ファイルの読み書きしたいなと思ったときにパッと思いついたのがfsモジュールだったのですが、これはVSCodeの拡張機能の実装では使わないほうがよさそうです。
APIが提供しているvscode.workspace.fsを使いましょう。
これはNode.jsのfsモジュールの代替になるモジュールで、SSHやWSLなどのリモート環境でのファイルシステムの違いを吸収してくれます。参考URL

ファイルの読み書きはUint8Array

VSCode APIのfsモジュールで読み込んだファイルはUint8Array型に格納されます。
そのままだと扱いづらいのでstringに変換して編集しました。

// 読み込んだファイルはUint8Array
const content:Uint8Array = await vscode.workspace.fs.readFile(file);
// toStringメソッドでstring型にできる。
const text = content.toString();
//stringからUint8Arrayへの変換はBufferクラスを挟んでfromメソッドから生成できる。
const outputBuf:Uint8Array = Uint8Array.from(Buffer.from(header + text));
await vscode.workspace.fs.writeFile(file, outputBuf); 

日時の処理はMoment.jsで

たぶん拡張機能の開発に限らない話ですが、日時を扱うときはMoment.jsを使うと便利です。
VSCode APIが提供するfs.statで得られるファイルの作成日時、変更日時はUNIX時間のミリ秒になります。
Moment.jsならUNIX時間からフォーマットされた文字列が一瞬で作れます。

function getFormattedTime(unixTime: number) {
  const dateTime = moment(unixTime);
  return dateTime.format('YYYY/MM/DD HH:mm:ss').toString();
}

special thanks:yokawada

完成した拡張機能がこちら

細かい実装についてはリポジトリをどうぞ。
https://github.com/nakamo326/42-header-oneshot

拡張機能の公開

実は僕自身はもうheaderが必要な課題をやる予定がないので、世界各地にある42 Networkの姉妹校の学生たちがこの拡張機能を使えるようMarektplaceに公開しようと思います。
https://code.visualstudio.com/api/working-with-extensions/publishing-extension
ここを参考に準備をしていきます。

AzureDevOpsのアクセストークンを作成する。

Marketplaceへの公開にはAzureDevOpsのアクセストークンが必要です。
AzureDevOpsの"githubの使用を無料で開始する"(原文ママ)からアカウント連携が行えました。

連携後、公式を参考にOrganizationを作成、personal access tokenを作成します。このトークンは後で必要になるので手元に保存しておいてください。

publisherを作成する。

Marketplaceのpublisher管理ページからpublisherを作成します。
この時作成した名前はpackage.json内にも保存しておきます。

package.json
{
	"name": "42-header-oneshot",
	"displayName": "42 Header OneShot",
	"description": "add 42 header to all sources in workspace",
	"publisher": "nakamo326",
	"version": "0.0.1",
// ...

vsceをインストール

拡張機能公開用のCLIツールvsceをインストールします。

npm install -g vsce

インストール後、先ほど作成したpublisherでloginします。

❯ vsce login nakamo326
https://marketplace.visualstudio.com/manage/publishers/
Personal Access Token for publisher 'nakamo326': ****************************************************

最後にvsce publishで拡張機能が公開できます!

……readmeはちゃんと書こう。

publishできました!

https://marketplace.visualstudio.com/items?itemName=nakamo326.42-header-oneshot

おわり

参考にした記事

https://zenn.dev/tomi/articles/2021-03-26-vscode-extension

https://stackoverflow.com/questions/55554018/purpose-for-subscribing-a-command-in-vscode-extension

https://code.visualstudio.com/updates/v1_37#_vscodeworkspacefs

https://github.com/microsoft/vscode/wiki/Adopting-Multi-Root-Workspace-APIs

https://qiita.com/high-moctane/items/526f25b8d2f46a2a246e

Discussion