VS Code にオリジナルモードを実装して Emacs のマーク・リージョンを実現した話

この記事は、[Supershipグループ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/supership) の 20 日目の記事になります。

## はじめに

本記事は、VS Code を使用している方や VS Code の Extension について知りたい方、Emacs から VS Code への乗り換えを検討している方向けの記事となっております。

本題に入る前に、少しだけ自己紹介（主に社内向けアピール）をさせてください。

### 自己紹介

はじめまして、Supership 21 卒の @ksfunc です。

プログラミング言語論や分散処理に興味があり勉強しています。Scala を好んで書いていますが、C、C++、Java、Kotlin、Dart、Haskell、Clojure、Erlang、Elixir、Sheme、Common Lisp、Rust、Go、JavaScript、TypeScript、Ruby、Python、PHP 何でも読み書きします。研究室のボスが Prolog 界隈の人だったので Prolog も微妙に読み書きできます。

Supership には広告配信の大規模トラフィックと闘いたいと言って入社しましたが、なぜか最近は React でフロントエンドの開発をしています（とても楽しいです）。

### エディターを制して人生を制する

エディターのカスタマイズ、していますか？

エディターをカスタマイズすることは、コーディングという体験をより心地よいものにすることであり、エンジニアの人生を豊かで華やかにするための一つの手段であると私は考えています。

エンジニアは毎日エディターと向き合い会話をしています。文字をタイプすればタイプした通りにテキストを編集してくれますし、矢印キーを押せばカーソルが動いてくれます（この記事の読者の多くは `Ctrl + FBNP` キーを押すかもしれませんが）。それはそれはとても楽しいものです。

しかし、今会話しているエディターも、いずれ歳を重ねて老朽化しまうかもしれません。テキストの編集はこれまで通りしてくれるかもしれませんが、新しい言語や開発スタイルにはなかなか順応してくれなくなってしまうことでしょう。

私も学生時代には Emacs を愛用していて init.el を何百行と成長させていましたが、最近の開発スタイルに耐えかねない状況となり、2020 年の春に VS Code へ乗り換えを行いました（[LSP Mode](https://emacs-lsp.github.io/lsp-mode/) まわりがもう少し整理されたらよかったのですが）。

強い意志を持って VS Code への乗り換えを決行したのですが、その際にボトルネックとなったのは、やはりキーバインディングとマーク・リージョンでした。一通り設定が完了するまで一時的に生産性が落ちるというのには、なかなか辛いものがあります。

VS Code にも Emacs ライクな操作を実現する Extension はいくつか存在しており、もちろん素晴らしいものもたくさんあります。ただし、キーバインディングに開発者独自のものが含まれていたりと開発者の思想が色濃く出ている感が否めませんでした。

幸いにも、キーバインディングの方は keybindings.json や [Karabiner-Elements](https://karabiner-elements.pqrs.org/) で設定することで理想に近づけることは容易いことでした。しかし、マーク・リージョンの方はマルチカーソルに対応していなかったり、組み込み以外のコマンドに対しての拡張性が乏しかったりと、私には許容できない部分がありました。

そこで、Emacs を使用していたときのように、VS Code でもキーバインディング以上のカスタマイズができるようになりたいと思い、Extension を自作することを決意しました。本記事では、VS Code の Extension の開発方法を簡単に紹介しつつ、実際に開発して公開した Extension の [Sticky Selection](https://marketplace.visualstudio.com/items?itemName=ksfunc.vscode-sticky-selection) を紹介していきます。

@whitphx さんによる [Awesome Emacs Keymap](https://marketplace.visualstudio.com/items?itemName=tuttieee.emacs-mcx) の実装や、その[紹介記事](https://qiita.com/whitphx/items/af8baa19fc4280ac1c0a)を大いに参考にさせていいただいております。二番煎じとなっておりますので、あしからずご理解ください。

## VS Code の Extension の開発方法

VS Code に限らず多くのエディターでは、基本的な拡張機能は「コマンド」をエントリーポイントとして提供されています。macOS の場合 `Cmd + Shift + P` キーを押すと一覧で表示されるアレです。

VS Code ではカーソル移動等を含めほとんどすべてのキー操作がコマンドにバインドされており、ユーザーは裏側でコマンドが実行された結果としてのカーソル移動や文字入力という体験を得ています。

したがって、なんらかの機能を独自実装したい場合、その機能をコマンドのインターフェイスに落とし込んで提供することが基本的なアイディアとなります。

独自コマンドの実装方法は、公式による [GET STARTED](https://code.visualstudio.com/api/get-started/your-first-extension) を試してみると理解できるようになります。ここではその一部を紹介します。

### テンプレートの生成

Node.js の実行環境が準備されていることを前提とします。

まず、[Yeoman](https://yeoman.io/) と [VS Code Extension Generator](https://www.npmjs.com/package/generator-code) を npm でインストールします。

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

続いて、Extension を作成したい任意のディレクトリにて下記のコマンドを実行して、テンプレートを生成していきます。

```bash
yo code
```

環境を汚したくない場合は npx を使用してもよいと思います。

```bash
npx -p yo -p generator-code -c "yo code"
```

すると、下記のように Extention の種類や使用したい言語について聞かれますので、今回は `New Extension (TypeScript)` を選択します。

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

? What type of extension do you want to create? (Use arrow keys)
❯ New Extension (TypeScript) 
  New Extension (JavaScript) 
  New Color Theme 
  New Language Support 
  New Code Snippets 
  New Keymap 
  New Extension Pack 
  New Language Pack (Localization) 
  New Web Extension (TypeScript) 
  New Notebook Renderer (TypeScript) 
```

続いて、Extension の名前を聞かれます。今回は公開までは行わないので、気軽に `HelloWorld` としておきます。

```
? What's the name of your extension? HelloWorld
```

他にもいろいろと聞かれますが、すべて何も入力せず `Enter` キーを押してデフォルトの設定にしておきます。

```
? What's the identifier of your extension? helloworld
? What's the description of your extension? LEAVE BLANK
? Initialize a git repository? Yes
? Bundle the source code with webpack? No
? Which package manager to use? npm

? Do you want to open the new folder with Visual Studio Code? Open with `code`
```

これでテンプレートが生成が完了します。

### 独自コマンドの実装

テンプレートの生成が完了すると、生成したテンプレートのディレクトリが自動的に VS Code で開かれます。この状態で `F5` キーを押すと、Extension がコンパイルされ、Extension Development Host というウィンドウが開かれます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327516/9914d3db-b1d3-2b11-7bd9-c498ff730450.png)

実は、このテンプレートには `Hello World` というタイトルのコマンドがすでに実装されています。コマンドパレットを開いて `Hello` まで入力すると、`Hello World` というタイトルのコマンドがサジェストに表示されると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327516/1f21e6a9-e70b-d28b-ed60-f70e2094e842.png)

実行すると、Snackbar にて `Hello World from HelloWorld!` と表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327516/bd58fc7b-4a29-24a2-25d2-626599260512.png)

このコマンドの実装を覗いてみます。src ディレクトリにある extension.ts を開きます。すると中段あたり下記のように書かれています。

```typescript
// The command has been defined in the package.json file
// Now provide the implementation of the command with registerCommand
// The commandId parameter must match the command field in package.json
let disposable = vscode.commands.registerCommand('helloworld.helloWorld', () => {
  // The code you place here will be executed every time your command is executed
  // Display a message box to the user
  vscode.window.showInformationMessage('Hello World from HelloWorld!');
});
```

vscode.commands.registerCommand 関数にて `helloworld.helloWorld` という ID でコマンドが登録されています。第 2 引数に渡している関数が、`helloworld.helloWorld` が呼び出された際に実行される処理となっています。

`Hello World from HelloWorld!` を `Hello VS Code` に書き換え、再度 `F5` キーを押して Extension Development Host を開き、先ほどと同じ手順で実行してみてください。Snackbar に表示されるメッセージが `Hello VS Code` に変わっているのが確認できると思います。

このように、基本的には vscode.window.showInformationMessage 関数のような VS Code が提供している API を駆使してコマンドを実装していきます。

利用可能な API は [VS Code API](https://code.visualstudio.com/api/references/vscode-api) にまとめられています。しかし、あまり親切にまとめられていないため、私が使用した API をいくつか紹介しておきます。

#### vscode.window.activeTextEditor

[window](https://code.visualstudio.com/api/references/vscode-api#window) は、現在開かれている VS Code のウィンドウを扱う名前空間です。そのうちの activeTextEditor 変数は、現在フォーカスされているタブの TextEditor オブジェクトを保持しています。

[TextEditor](https://code.visualstudio.com/api/references/vscode-api#TextEditor) オブジェクトは、カーソルの位置や選択範囲などの編集状況を管理しています。Emacs でいうところのバッファーのようなものです。TextEditor は TextDocument オブジェクトにアタッチされています。

[TextDocument](https://code.visualstudio.com/api/references/vscode-api#TextEditor) オブジェクトは、ファイル名や行数などのドキュメントに関する情報を管理しています。複数の TextEditor オブジェクトが 1 つの TextDocument オブジェクトにアタッチされる場合があります。同じドキュメントを複数のタブで開いている場合がこれに該当します。

#### editor.selections

TextEditor の selections プロパティは、Selection オブジェクトの配列です。VS Code はマルチカーソルに対応しているため、配列となっています。

[Selection](https://code.visualstudio.com/api/references/vscode-api#Selection) オブジェクトは、カーソルの位置や選択範囲を管理しています。anchor プロパティと active プロパティがあり、anchor プロパティは選択範囲の起点を表しており、active プロパティはカーソルの位置を表しています。つまり、anchor と active の間が選択範囲となります。Selection という名前のオブジェクトでカーソルの位置を管理していることを少々奇妙に思うかもしれませんが、選択範囲持たない状態は anchor と active が一致している状態で表現されています。

#### vscode.commands.executeCommand

[commands](https://code.visualstudio.com/api/references/vscode-api#commands) は、コマンドを扱う名前空間です。そのうちの executeCommand 関数を使用すると、組み込みのコマンドなどの他のコマンドを呼び出すことができます。

## [Sticky Selection](https://marketplace.visualstudio.com/items?itemName=ksfunc.vscode-sticky-selection)

ここからは、私が開発して公開した Extension の [Sticky Selection](https://marketplace.visualstudio.com/items?itemName=ksfunc.vscode-sticky-selection) でできることを紹介しつつ、どのように実装をしたのかを紹介していきます。

### できること

この Extension をインストールすると、TextEditor にて `Ctrl + Space` キーを押すと、sticky-selection-mode に入るようになります。`Ctrl + Space` キーでサジェストを表示させるようにしている場合には、下記のように keybindings.json を編集して他のキーに割り当てるようにしてください。

```json
[
  {
    "key": "任意のキー",
    "command": "sticky-selection.enterStickySelectionMode",
    "when": "editorTextFocus"
  },
  {
    "key": "ctrl+space",
    "command": "-sticky-selection.enterStickySelectionMode",
    "when": "editorTextFocus"
  }
[
```

sticky-selection-mode に入ると、`Shift` キーを押さずにカーソル移動しても `Shift` キーを押している場合と同様に選択しながらカーソル移動されるようになります。

`Escape` キーを押すと、sticky-selection-mode を抜けます。`Escape` キーが気に入らない場合には、`Ctrl + G` キーなどの他のキーに割り当ててください。私は Karabiner-Elements にて `Ctrl + G` キーを `Escape` キーに振り替えています。

`Ctrl + FBNP` キーを含め VS Code がデフォルトで提供しているカーソル移動系のキーにはすべて対応していますが、人によってはデフォルト以外の別のキーにカーソル移動系のコマンドを割り当てているかもしれません。その場合は、keybindings.json の when 条件に `inStickySelectionMode` を追加することで、sticky-selection-mode にいる場合には選択系のコマンドを実行させるようにすることができます。

たとえば、`Ctrl + V` キーや `Alt + V` キーにページ移動を割り当てている場合には、下記のように設定することで sticky-selection-mode にいる場合にはページ選択をさせることができます。

```json
[
  {
    "key": "ctrl+v",
    "command": "cursorPageDown",
    "when": "textInputFocus"
  },
  {
    "key": "alt+v",
    "command": "cursorPageUp",
    "when": "textInputFocus"
  },
  {
    "key": "ctrl+v",
    "command": "cursorPageDownSelect",
    "when": "editorTextFocus && inStickySelectionMode"
  },
  {
    "key": "alt+v",
    "command": "cursorPageUpSelect",
    "when": "editorTextFocus && inStickySelectionMode"
  }
]
```

sticky-selection-mode に入って選択をし、`Cmd + C` キーでコピーをすると、そのままでは sticky-selection-mode にいる状態が継続されます。sticky-selection-mode を抜けるようにしたい場合には、下記のようにして `sticky-selection.exitStickySelectionMode` コマンドを使用します。args の command に渡したコマンドを実行したのちに、sticky-selection-mode を抜けるようになります。ここでも when 条件に `inStickySelectionMode` を追加していることに注意してください。

```json
[
  {
    "key": "cmd+c",
    "command": "sticky-selection.exitStickySelectionMode",
    "args": {
      "command": "editor.action.clipboardCopyAction",
      "delay": 100
    },
    "when": "editorTextFocus && inStickySelectionMode"
  }
]
```

同様のマナーで、他のコマンドに対するサポートも提供することができます。

続いて、sticky-selection-mode をどのように実装したのかを紹介していきます。

### 基本は Vim のモードと同じ

Emacs のマーク・リージョンは、「マークをセットした状態 ＝ 選択モードにいる」と解釈すれば、実は Vim のモードにとてもよく似ています。

Vim の場合は、入力モードでキーをタイプすると文字が入力され、ノーマルモードでキーをタイプするとコマンドが実行されます。今回の場合は、通常モードでカーソルを移動すると通常通りにカーソルが移動し、選択モードでカーソルを移動すると選択しながらカーソルが移動するという具合です。

Vim のモードに似ているということは、Microsoft の公式による Extension のサンプル集にある [vim-sample](https://github.com/microsoft/vscode-extension-samples/tree/main/vim-sample) を参考にすることができます（残念ながら Emacs のサンプルはありませんでした）。

### Extension 側でするべきこと

vim-sample を眺めると、Extension 側で何をするべきなのかが見えてきます。Extension 側でするべきことは、主に下記の 3 つになります。

- 選択モードにいるかどうかの状態を保持すること。
- 選択モードに入るコマンドと選択モードを抜けるコマンドを提供すること。
- 選択モードにいるかどうかを外部から参照できるようにすること。

#### 選択モードにいるかどうかの状態を保持する

まず、選択モードにいるかどうかをなんらかのオブジェクトにて保持しなくてはなりません。vim-sample では Controller という名前のクラスのオブジェクトに保持しています。私もこれに倣い、Controller クラスのオブジェクトのフィールドで保持するように実装をしました。

```typescript
export default class Controller {
  private isInStickySelectionMode: boolean;

  constructor() {
    this.isInStickySelectionMode = false;
  }

  async activate() {
    await this.ensureContext();
  }
}
```

#### 選択モードに入るコマンドと選択モードを抜けるコマンドを提供する

続いて、選択モードに入るコマンドと選択モードを抜けるコマンドを実装します。まずは Controller クラスの方に、保持している状態を切り替えるメソッドを追加します。一部を抜粋すると下記のようになります（全体のソースコードは [GitHub](https://github.com/ksfunc/vscode-sticky-selection) にあります）。

```typescript
export default class Controller {
  private isInStickySelectionMode: boolean;

  async enterStickySelectionMode() {
    this.isInStickySelectionMode = true;
    await this.ensureContext();
  }

  async exitStickySelectionMode(
    editor: vscode.TextEditor, command?: Command, delay?: number
  ) {
    if (command !== undefined) {
      await command.execute();
      if (delay !== undefined && 0 < delay) {
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }
    this.removeSelections(editor);
    this.isInStickySelectionMode = false;
    await this.ensureContext();
  }
}
```

続いて、extension.ts の方にこれらのメソッドを呼び出すコマンドを実装します。一部を抜粋すると下記のようになります。

```typescript
export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand(
      "sticky-selection.enterStickySelectionMode", 
      async () => {
        const editor = vscode.window.activeTextEditor;
        if (!editor) {
          return;
        }

        const controller = getOrCreateController(editor);
        await controller.enterStickySelectionMode();
      }
    )
  );

  context.subscriptions.push(
    vscode.commands.registerCommand(
      "sticky-selection.exitStickySelectionMode",
      async (args) => {
        const editor = vscode.window.activeTextEditor;
        if (!editor) {
          return;
        }

        const controller = getOrCreateController(editor);
        if (args && args.command) {
          const command = new Command(args.command, args.args ? args.args : null);
          if (args.delay) {
            await controller.exitStickySelectionMode(editor, command, args.delay);
          } else {
            await controller.exitStickySelectionMode(editor, command);
          }
        } else {
          await controller.exitStickySelectionMode(editor);
        }
      }
    )
  );
}
```

#### 選択モードにいるかどうかを外部から参照できるようにする

最後に、選択モードにいるかどうかを外部から参照できるようにします。Controller クラスのオブジェクトのフィールドに状態を保持するだけでは外部から参照することができないため、組み込みの `setContext` コマンドを使用して `inStickySelectionMode` というコンテキストをセットします。

```typescript
export default class Controller {
  private isInStickySelectionMode: boolean;

  async ensureContext() {
    await vscode.commands.executeCommand(
      "setContext", "inStickySelectionMode", this.isInStickySelectionMode
    );
  }
}
```

Context にセットしたコンテキストは、keybindings.json の when 条件などで参照することができます。

これ以外にも、マルチカーソルの対応やタブごとに状態を分離する処理などが必要となりますが、根幹の実装はこれだけになります。とても簡単に、オリジナルモードを実装することができることができました。

これにて、VS Code でもエレガントなコーディング体験を得られるようになりました。

## おわりに

本記事では、VS Code の Extension の開発方法を簡単に紹介し、Sticky Selection を例としてオリジナルモードの実装方法を紹介しました。

オリジナルモードが実装できるようになると、ステートレスなコマンドだけではなく、ステートフルなコマンドも実装できるようになり、実装できる機能の幅が広がります。非常に応用の効くものとなっているので、試してみてはいかがでしょうか。

### 宣伝

Supershipグループではプロダクト開発やサービス開発に関わる人材を絶賛募集しております。ご興味がある方は以下リンクよりご確認ください。

[Supershipグループ 採用サイト](https://supership-hd.jp/recruit/)

是非ともよろしくお願いいたします。