業務で役に立つVS Code機能拡張を作ってみた話

この記事は UIT 新春 Tech blog 3日目の記事です。

こんにちは。LINE証券のフロントエンドを開発している岩本海童です。
今日は、業務で利用できる便利なVS Codeの機能拡張を作ってみた話をします。

背景

LINEでは、XLTと呼ばれるアプリケーションの多言語化のための社内ツールがあります。
あるキーとなる文字列に対して、対応する日本語や英語の文字列を関連づけることができ、それをソースコードから参照します。

例えば次のように、ボタンにXLTから設定した文字列を表示します。

export const ShowAllAssetsButton: FC = () =>
  <button>{xlt('portofolio.showAllAssets')}</button>
 
// 日本語環境の場合は <button>資産を全て表示</button>
// 英語環境の場合は <button>Show all assets</button> のようにレンダリングされる

当然ですが、ソースコードにはキーが書いてあるだけなので、わかりづらいです。
紐づいている文字列を確認することも難しくはないのですが、少々面倒に感じていました。

そこで、それをエディタ上に表示するVS Code(Visual Studio Code)の機能拡張を作ってみました。

ここでは、今回作った機能拡張の作り方を、大幅に単純化して紹介したいと思います。

仕様

.tsまたは.tsxファイル内で、translateという名前の関数を単一の文字列リテラルを引数にして呼び出している箇所を探し、 その呼び出しの後ろに引数の文字列を大文字にして表示します。

例:

const e = document.getElementById('foo')
e.textContent = translate('foo.bar') /* FOO.BAR ←ここに表示する */

作り方

0. 調査

公式のガイドで、機能拡張の雛形を作って、デバッグする手順が紹介されています。 とても簡単です。

次に、目的の機能をどんなAPIを使って実装すれば良いのか調査します。
Inline Parameters for VSCodeという、関数の引数の名前を表示する機能拡張があったので、こちらのソースを見てみました。

TextEditor#setDecorationsというAPIを使っているようだったので、真似してみます。

APIのリファレンスも、やりたいことをどう実現するのか調査するのに役立つと思います。
また、Extension Guidesなど、参考になる公式のドキュメントが多数あります。

1. プロジェクト生成と簡単な準備

公式のガイドに従い、Yeomanでプロジェクトを生成します。
最初にTypeScriptを選ぶ以外は適当です。

$ yo code
 
? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? sample-extension
? What's the identifier of your extension? sample-extension
? What's the description of your extension?
? Initialize a git repository? Yes
? Bundle the source code with webpack? Yes
? Which package manager to use? npm

生成されたファイルの中で、一番重要なのはpackage.jsonsrc/extension.tsです。

まず、package.jsonactivationEventscontributesを次のように変更します。

...
"activationEvents": [
  "onLanguage:typescript",
  "onLanguage:typescriptreact"
],
...
"contributes": {},
...

activationEventsをこのように設定することで、TypeScriptのファイルを開いたときにこの機能拡張が初期化されるようになります。
contributesは必要ないので空にします。

また、typescriptをdevDependenciesからdependenciesに移動します。
(ランタイムでTypeScriptのCompiler APIを利用するため。)

// ...
"dependencies": {
  "typescript": "^4.4.4"
},
// ...

次に、src/extension.tsにあるactivate内に生成されたコードを消して空にします。
(コメントも消しています。)

import * as vscode from 'vscode';
 
export function activate(context: vscode.ExtensionContext) {
}
 
export function deactivate() {}

機能拡張が初期化される際はこのactivate関数が呼ばれ、無効化された際などにはdeactivateが呼ばれます。
activate関数内に色々な処理を書くことで、さまざまな機能を実装していきます。

ひとまず、activate関数に簡単な処理を書いておきましょう。

export function activate(context: vscode.ExtensionContext) {
  vscode.workspace.onDidChangeTextDocument(
    (event) => {
      const editor = vscode.window.visibleTextEditors.find(
        (e) => e.document === event.document
      );
      if (editor !== undefined) {
        updateDecorations(editor);
      }
    },
    null,
    context.subscriptions
  );
}
 
function updateDecorations(editor: vscode.TextEditor) {
  const language = editor.document.languageId;
  if (language !== "typescript" && language !== "typescriptreact") return;
 
  const code = editor.document.getText();
 
  // codeから関数呼び出しを見つけてデコレーションを表示する
}

このコードでは、どこかのエディタに変更があれば、updateDecorations関数を呼び出すというコードになっています。
updateDecorations関数では、エディタの言語がTypeScriptである場合はそのソースコードを処理していきます。

さて、この後は、ASTを解析して目当ての関数呼び出しを探す処理と、関数呼び出しの後にデコレーションを表示する処理を書いていきます。

2. AST解析編

TypeScriptのASTを解析するには、TypeScript Compiler APIを利用します。
公式のサンプルコードがあったのでそれを見て雰囲気を理解しました。

Using the Compiler API · microsoft/TypeScript Wiki · GitHub

また、TypeScript AST Viewerというものがあり、ASTの構造を理解するのに役に立ちます。

ひとまず、TypeScriptをimportします。

import * as ts from "typescript";

次に、findFunctionCallsという関数を追加し、そこでソースコード中のtranslate関数の呼び出しを探します。

type FunctionCall = {
  start: number; // 関数呼び出しのソースコード上の開始位置
  end: number; // 関数呼び出しのソースコード上の終了位置
  key: string; // 引数に渡されたキー名
};
 
function updateDecorations(editor: vscode.TextEditor) {
  // ...
  const code = editor.document.getText();
  const calls = findFunctionCalls(code, language === 'typescriptreact')
  // ...
}
 
// translate関数の呼び出しを探す
function findFunctionCalls(
  code: string,
  isReact: boolean
): Array<FunctionCall> {
  const sourceFile = ts.createSourceFile(
    "_.ts",
    code,
    ts.ScriptTarget.ESNext,
    true,
    isReact ? ts.ScriptKind.TSX : ts.ScriptKind.TS
  );
 
  const calls: Array<FunctionCall> = [];
 
  function search(node: ts.Node) {
    block: {
      if (ts.isCallExpression(node)) {
        // 識別子を用いた単純な関数呼び出しではない場合は対象外
        if (!ts.isIdentifier(node.expression)) break block;
 
        const name = node.expression.text;
        // 関数名が `translate` 以外の場合は対象外
        if (name !== "translate") break block;
 
        // 関数の引数が1個ではない場合は対象外
        if (node.arguments.length !== 1) break block;
 
        const arg0 = node.arguments[0];
        // 第1引数が文字列リテラル以外の場合は対象外
        if (!ts.isStringLiteral(arg0)) break block;
 
        const key = arg0.text;
        calls.push({ key, start: node.getStart(), end: node.getEnd() });
        return;
      }
    }
 
    ts.forEachChild(node, search);
  }
 
  search(sourceFile);
 
  return calls;
}

isReactという名前の引数は、.tsファイルと.tsxファイルを区別するための引数です。(trueの場合が後者。)
.tsのコードと.tsxのコードは文法に少し違いがあり、今回は両方を受け付けているので、パースする際にどちらなのかを明示します。

この関数では、まずソースコードをパースしてSourceFileという型のオブジェクトを作ります。 これがASTのルートノードになります。

次に、searchという関数を定義し、ASTを再帰的に見ていきます。
関数呼び出しの式が見つかり、さらに式が条件を満たす場合は、情報を配列に詰めます。

ASTを解析する処理は、上に挙げたAST Viewerを確認したり、コード補完を活用したり、あるいは実際に動かしてみたりして試行錯誤しながら書くことになります。
TypeScriptのバージョンが変わるとASTが多少変わることもあるので、注意が必要です。

3. デコレーションを表示する

まず、updateDecorationsの前にdecorationTypeという定数を追加します。
これは、デコレーションを表示するために必要なオブジェクトで、デフォルトの装飾オプションを指定することができます。
(個別にも設定することができます。)

const decorationType = vscode.window.createTextEditorDecorationType({
  after: {
    color: "#fff",
    backgroundColor: "#333",
    textDecoration: `;
      padding: 0.1em;
      margin: 0.2em;
      border-radius: 0.2em;
    `,
  },
});
 
function updateDecorations(editor: vscode.TextEditor) {
  // ...

次に、updateDecorationsにコードを追加してデコレーションを表示します。

function updateDecorations(editor: vscode.TextEditor) {
  const language = editor.document.languageId;
  if (language !== "typescript" && language !== "typescriptreact") return;
 
  const code = editor.document.getText();
 
  const calls = findFunctionCalls(code, language === "typescriptreact");
  // 以降を追加
 
  const decorations = calls.map(
    ({ start, end, key }): vscode.DecorationOptions => {
      const range = new vscode.Range(
        editor.document.positionAt(start),
        editor.document.positionAt(end)
      );
 
      return {
        range,
        renderOptions: {
          after: {
            contentText: key.toUpperCase(),
          },
        },
      };
    }
  );
 
  editor.setDecorations(decorationType, decorations);
}

見て分かる通り、ただ表示範囲とテキストを設定しているだけですね。

表示範囲は、Rangeというオブジェクトを使います。
Rangeに渡す開始と終了の位置は、行と行内のオフセットで表現されたPositionというオブジェクトで指定する必要があります。
findFunctionCallsから帰ってくるstart,endはソースコード内のオフセットなので、TextDocument#positionAtメソッドでPositionに変換しています。

4. 完成

これで機能拡張のコードが全て書けました。

extension.tsの全体を掲載しておきます。

import * as vscode from "vscode";
import * as ts from "typescript";
 
const decorationType = vscode.window.createTextEditorDecorationType({
  after: {
    color: "#fff",
    backgroundColor: "#333",
    textDecoration: `;
      padding: 0.1em;
      margin: 0.2em;
      border-radius: 0.2em;
    `,
  },
});
 
type FunctionCall = {
  start: number; // 関数呼び出しのソースコード上の開始位置
  end: number; // 関数呼び出しのソースコード上の終了位置
  key: string; // 引数に渡されたキー名
};
 
export function activate(context: vscode.ExtensionContext) {
  vscode.workspace.onDidChangeTextDocument(
    (event) => {
      const editor = vscode.window.visibleTextEditors.find(
        (e) => e.document === event.document
      );
      if (editor !== undefined) {
        updateDecorations(editor);
      }
    },
    null,
    context.subscriptions
  );
}
 
export function deactivate() {}
 
function updateDecorations(editor: vscode.TextEditor) {
  const language = editor.document.languageId;
  if (language !== "typescript" && language !== "typescriptreact") return;
 
  const code = editor.document.getText();
 
  const calls = findFunctionCalls(code, language === "typescriptreact");
 
  const decorations = calls.map(
    ({ start, end, key }): vscode.DecorationOptions => {
      const range = new vscode.Range(
        editor.document.positionAt(start),
        editor.document.positionAt(end)
      );
 
      return {
        range,
        renderOptions: {
          after: {
            contentText: key.toUpperCase(),
          },
        },
      };
    }
  );
 
  editor.setDecorations(decorationType, decorations);
}
 
// translate関数の呼び出しを探す
function findFunctionCalls(
  code: string,
  isReact: boolean
): Array<FunctionCall> {
  const sourceFile = ts.createSourceFile(
    "_.ts",
    code,
    ts.ScriptTarget.ESNext,
    true,
    isReact ? ts.ScriptKind.TSX : ts.ScriptKind.TS
  );
 
  const calls: Array<FunctionCall> = [];
 
  function search(node: ts.Node) {
    block: {
      if (ts.isCallExpression(node)) {
        // 識別子を用いた単純な関数呼び出しではない場合は対象外
        if (!ts.isIdentifier(node.expression)) break block;
 
        const name = node.expression.text;
        // 関数名が `translate` 以外の場合は対象外
        if (name !== "translate") break block;
 
        // 関数の引数が1個ではない場合は対象外
        if (node.arguments.length !== 1) break block;
 
        const arg0 = node.arguments[0];
        // 第1引数が文字列リテラル以外の場合は対象外
        if (!ts.isStringLiteral(arg0)) break block;
 
        const key = arg0.text;
        calls.push({ key, start: node.getStart(), end: node.getEnd() });
        return;
      }
    }
 
    ts.forEachChild(node, search);
  }
 
  search(sourceFile);
 
  return calls;
}

機能拡張を実行するには、サイドバーの”Run and Debug”を選択し、”Run Extension”を選択して実行します。
新しいVS Codeのウインドウが開くので、適当なコードを入力して試します。

上のスクリーンショットのように、期待通りにデコレーションがエディタ上に表示されました。

最後に、書いた機能拡張をパッケージ化するには、vsceというコマンドラインツールを使います。

npm install -g vsce
vsce package

拡張子.vsixのファイルが生成されるので、VS Codeの機能拡張のメニューから”Install from VSIX” からファイルを選択してインストールすることができます。

私が社内用に作った機能拡張では、これに加えて、キーに紐づく文字列を取得して表示する処理や、有効にしたワークスペースのみで動作する仕組みなどを実装しました。
ソースコードが読みやすくなって大変良かったです。

↓実際に動作している様子はこんな感じです。

まとめ・感想

  • VS Codeの機能拡張の開発は、公式のドキュメントやプロジェクトテンプレートが充実しており、手軽に始めることができます
  • 機能拡張を作る際は、似た機能を持つ他の機能拡張のソースを読むと参考になります
  • TypeScript Compiler APIを利用することで、TypeScriptやJavaScriptのコードを解析して、エディタ上に情報を表示する機能拡張を作ることができます
  • LINE証券のプロジェクトで使用している、多言語化のための社内システムと組み合わせた機能拡張を作ったところ、便利なものができました

VS Codeの機能拡張を作るのは初めてでしたが、公式の資料のおかげで比較的簡単に作ることができました。
普段使っているエディタを、普段使っているTypeScriptという言語で拡張できるのは大変楽しいです。
皆さんもぜひ、自分の・チームの課題を解決するための機能拡張を作ってみてください。

おまけ: 機能拡張をワークスペースでデバッグする

些細な情報ではありますが、私がみた限りではWebでこのような情報が見当たらなかったので、ついでに書いてみます。

yo codeが生成したデフォルトの設定では、デバッグするたびにワークスペースに紐づかないウインドウが開いてしまいます。
ワークスペースで動作する機能拡張を開発するときには、ワークスペースを開きたいものです。

ワークスペースでデバッグするためには、.vscode/launch.jsonを編集します。

// ...
"configurations": [
  {
    "name": "Run Extension",
    // ...
    "args": [
      "--extensionDevelopmentPath=${workspaceFolder}",
      "${env:HOME}/path-to-workspace" // ← この行を追加する
    ],
    // ...

このような感じで、”Run Extension”があるオブジェクトのargsにワークスペースのパスを追加します。
これで、”Run Extension”を実行するとそのワークスペースでデバッグが行えるようになります。

ただし、既にそのワークスペースをVS Codeで開いている場合にはデバッグが行えないので、閉じておく必要があります。
(当たり前だろ、という感じですが、ハマってしまったことがあります。)

実は、このargsは、codeコマンドに渡される引数なのです。
なので、最後にワークスペースのパスを指定することで、ワークスペースを開いてデバッグすることができるのです。
もちろん、他のオプションを指定することもできます。

以上、機能拡張を開発する人向けのTipsでした。

UIT 新春 Tech blog記事一覧

  1. Web フォントを使って contenteditable から脱出する
  2. 業務で見つけた! Conditional Types
  3. 業務で役に立つVS Code機能拡張を作ってみた話
  4. フロントエンド開発の業務を支えるちょっと珍しいチームのご紹介
  5. 社内のデザイナーの業務をサポートする LDSG Figma Plugin の工夫したところ、ハマりどころ
  6. 2022年におけるフロントエンド開発のベースライン
  7. Prettier への支援開始のお知らせと企業が OSS に対して支援するということ
  8. 続 LINE 社内用 NPMパッケージの管理戦略