Blog
ブログ

2026年04月30日

【4月技術ブログ】WasmとVSCode

こんにちは!新入社員ではなくなったYです。今回の技術ブログでは、私が普段の業務で抱えてる不満とそれを解決するツールについての話です。

VSCodeとアーキテクチャ

皆さんはアーキテクチャはお好きでしょうか?私は好きです。コードの見通しが良くなるし、どこに何が書いてあるのかが一定程度は把握しやすくなるし、仕様が固まっていない状態で実装を行って手戻りが発生した時にも修正が効きやすいからです。仕様が固まっていない方をまず解決すべきだ、とかそういう話は一旦置いておきます。

ところで皆さんはプログラミングを行う際にはどのようなエディタを使っているでしょうか?2025年のStack Overflowの調査によれば75.9%の人がVisual Studio Code(VSCode)を使っているそうです。私も多くの業務ではVSCodeを使っています。そんなVSCodeにはさまざまな拡張機能があり、コードを書いて保存したときに自動でインデントを調整してくれるものだったり、コード補完を出してくれるものであったり、また最近ではAIがコードを書いてくれたりと非常に便利です。

そんな便利なVSCodeですが一つ私が許せないことがあります。フォルダの入れ替えができないことです。名前順などでのソートはできても、自由な入れ替えはできないのです。レイヤードアーキテクチャのように依存が一方向に決まっているアーキテクチャを考えましょう。フォルダがapi、service、repository、coreのように分かれているとして、依存の方向は記述した通りです。そのため、フォルダもその通りに並んでいてほしいですが、実際にはapi、core、repository、serviceのように並んでしまいます。依存の関係を考えると例えばapi層ではservice層から何が返ってくるのかを知りたいけれども、そのためには分厚いフォルダの壁をいくつも越えなければいけません。これはストレスです!!

 

Wasmとは

話は変わって今回のブログタイトルにもあるWasmをご存知でしょうか?WebAssemblyの略で、C/C++、C#、Rustなどのコードをコンパイルしたときに生成されるバイナリ形式のアセンブリー風の言語です。WasmはJavaScriptから呼び出し可能で、Webフロントエンドなどのように本来JavaScriptで記述するような領域でも、JavaScriptからWasmを呼び出すことにより、高速な処理を可能にする技術として注目されています。

https://developer.mozilla.org/ja/docs/WebAssembly

VSCode拡張機能作り

VSCodeの拡張機能は主にJavaScriptやTypeScriptで作成します。開発中にエディタ自体の処理が重いのは仕事のモチベーションに関わるので、拡張機能には高速な処理を期待したいところです。ということで、VSCodeの拡張機能はまさにWasmが活躍できる舞台です。

以下に自作したVSCodeの拡張機能のコードと結果の例を示します。機能としては、エディタ上でクリックした部分の行数や列番号などを表示するだけの至ってシンプルなものです。いきなり本命のフォルダ並び替え拡張機能に行く前に、まずはこのデモでRust + Wasmの呼び出し方を押さえていきましょう。

プロジェクト構成

project/
├── package.json
├── tsconfig.json
├── ts/
│   └── extension.ts
├── wasm/              ← wasm-packの出力先
│   ├── click_wasm.js
│   ├── click_wasm_bg.wasm
│   └── ...
└── rust/              ← Rustプロジェクト
    ├── Cargo.toml
    └── src/
        └── lib.rs

package.json

VSCode拡張機能のマニフェストファイルです。今回の開発では、まずはTypeScriptのみで作成したあと、一部の処理をRust、Wasmに委譲するという流れで行います。そのため、初めは不要なフィールドも以下には含まれていますが気にしないでください。

{
  "name": "rust-click-demo",
  "displayName": "Rust Click Demo",
  "description": "エディタクリックでRust WASMを呼び出すデモ",
  "version": "0.0.1",
  "engines": {
    "vscode": "^1.85.0"
  },
  "categories": ["Other"],
  "activationEvents": ["*"],
  "main": "./ts/extension.js",
  "contributes": {
    "commands": [
      {
        "command": "rust-click-demo.analyze",
        "title": "Rust: Analyze Current Line"
      }
    ]
  },
  "scripts": {
    "vscode:prepublish": "npm run compile",
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./"
  },
  "devDependencies": {
    "@types/vscode": "^1.85.0",
    "@types/node": "^20.0.0",
    "typescript": "^5.3.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "sourceMap": true,
    "rootDir": "./ts",
    "strict": true
  },
  "exclude": ["node_modules", ".vscode-test"]
}

まずはTypeScriptだけで作る

いきなりWasmを使うのではなく、まず純粋なTypeScriptで動く拡張機能を作ります。エディタ上でカーソルが移動するたびに、現在行の情報(行番号・列番号・文字数・単語数・コメント行かどうか)をステータスバーに表示します。

使う主なVSCode APIは以下のとおりです。

API 役割
activate(context) 拡張機能の起動時に呼ばれるエントリポイント
vscode.window.createStatusBarItem() ステータスバーにアイテムを追加
vscode.window.onDidChangeTextEditorSelection() カーソル移動イベントを購読
vscode.commands.registerCommand() コマンドを登録
context.subscriptions.push() 拡張機能の停止時に自動でdispose()されるリストに登録
import * as vscode from "vscode";
import { Line, analyze_line } from '../wasm/click_wasm';

function analyzeWithRust(
  lineText: string,
  lineNumber: number,
  charIndex: number,
): string {
  // const line = new Line(lineText, lineNumber, charIndex);
  // return analyze_line(line)
  const wordCount = lineText.trim() === "" ? 0 : lineText.trim().split(/\s+/).length;
  const charCount = lineText.length;
  const isComment =
    lineText.trimStart().startsWith("//") ||
    lineText.trimStart().startsWith("#") ||
    lineText.trimStart().startsWith("--");

  return `行:${lineNumber + 1} | 列:${charIndex} | ${charCount}文字 | ${wordCount}単語 ${isComment ? "コメント行" : ""}`;
}

export function activate(context: vscode.ExtensionContext) {
  const statusBar = vscode.window.createStatusBarItem(
    vscode.StatusBarAlignment.Left,
    100,
  );

  statusBar.text = `${circle-online} クリックで解析`;
  statusBar.tooltip = "エディタをクリックすると行の情報を表示します";
  statusBar.command = "rust-click-demo.analyze";
  statusBar.show();

  const clickHandler = vscode.window.onDidChangeTextEditorSelection((event) => {
    const editor = event.textEditor;
    const selection = event.selections[0];
    const line = selection.active.line;
    const char = selection.active.character;
    const lineText = editor.document.lineAt(line).text;

    const result = analyzeWithRust(lineText, line, char);
    statusBar.text = `$(zap) ${result}`;
    highlightCurrentLine(editor, line);
  });

  const analyzeCommand = vscode.commands.registerCommand(
    "rust-click-demo.analyze",
    () => {
      const editor = vscode.window.activeTextEditor;
      if (!editor) {
        vscode.window.showWarningMessage("エディタが開いていません。");
        return;
      }
      const line = editor.selection.active.line;
      const char = editor.selection.active.character;
      const lineText = editor.document.lineAt(line).text;
      const result = analyzeWithRust(lineText, line, char);
      vscode.window.showInformationMessage(`🦀 Rust解析結果: ${result}`);
    },
  );

  context.subscriptions.push(statusBar, clickHandler, analyzeCommand);
}

let currentDecoration: vscode.TextEditorDecorationType | undefined;

function highlightCurrentLine(editor: vscode.TextEditor, lineNumber: number) {
  if (currentDecoration) {
    currentDecoration.dispose();
  }

  currentDecoration = vscode.window.createTextEditorDecorationType({
    backgroundColor: new vscode.ThemeColor("editor.lineHighlightBackground"),
    isWholeLine: true,
    border: "1px solid",
    borderColor: new vscode.ThemeColor("focusBorder"),
  });

  const range = editor.document.lineAt(lineNumber).range;
  editor.setDecorations(currentDecoration, [range]);
}

export function deactivate() {
  if (currentDecoration) {
    currentDecoration.dispose();
  }
}

動作確認は npm run compile の後、F5 キーで Extension Development Host を起動すればOKです。

 

処理をRustに委譲する

本題はここからです。analyzeWithRust の中身をRustに移します。委譲する処理は次の3つです。

処理 内容
文字数カウント lineText.length で行の総文字数を取得
単語数カウント 空白文字で分割してトークン数を数える。空行は0
コメント行判定 行頭が //#-- のいずれかで始まればコメント行とみなす

Cargo.toml

[package]
name = "click_wasm"
version = "0.1.0"
edition = "2024"

[dependencies]
wasm-bindgen = "0.2.114"

[lib]
crate-type = ["cdylib"]

[profile.release]
opt-level = "s"

crate-type = ["cdylib"] でWasm向けの共有ライブラリとしてビルドします。wasm-bindgen はJavaScriptとRustの間での値の受け渡しを可能にするクレートです。

src/lib.rs

#[wasm_bindgen] アトリビュートを付けた構造体・関数がJavaScript側から呼び出せるようになります。

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Line {
    line_text: String,
    line_number: u32,
    char_index: u32,
}

#[wasm_bindgen]
impl Line {
    #[wasm_bindgen(constructor)]
    pub fn new(line_text: String, line_number: u32, char_index: u32) -> Line {
        Line { line_text, line_number, char_index }
    }
}

#[wasm_bindgen]
pub fn analyze_line(line: &Line) -> String {
    let word_count = line.line_text.split_whitespace().count().to_string();
    let char_count = line.line_text.chars().count().to_string();
    let is_comment = line.line_text.trim_start().starts_with("//")
        || line.line_text.trim_start().starts_with("#")
        || line.line_text.trim_start().starts_with("--");
    let comment = if is_comment { "コメント行" } else { "" };

    format!(
        "行: {}, 列: {}, 文字数: {}, 単語数: {} {}",
        line.line_number, line.char_index, char_count, word_count, comment
    )
}

Wasmのビルド

wasm-pack build --target nodejs --out-dir ../wasm

wasm/ に以下のファイルが生成されます。

wasm/
├── click_wasm.js       ← JS/Wasmの橋渡し
├── click_wasm_bg.wasm  ← Wasmバイナリ
├── click_wasm.d.ts     ← TypeScript型定義
├── click_wasm_bg.wasm.d.ts
└── package.json

生成された click_wasm.js を覗くと、文字列のエンコード・デコードやメモリ管理をJavaScript側で担っていることがわかります。wasm-bindgen がこの橋渡しコードを自動生成してくれるおかげで、Rust側は純粋なロジックだけに集中できます。

TypeScriptとつなぐ

先ほど書いたTypeScriptコードのコメントアウトを外すだけです。

function analyzeWithRust(lineText: string, lineNumber: number, charIndex: number): string {
  const line = new Line(lineText, lineNumber, charIndex);
  return analyze_line(line);
}

npm run compileF5 で動作確認できます。これで「TypeScriptが受け取ったエディタの状態をRustに渡し、Rustが解析して結果文字列を返す」という最小構成が動きます。あとはこの仕組みを応用するだけです。

欲しいモノは自分で作ろう

クリックデモで基本は押さえたので、いよいよ本命のフォルダ並び替え拡張機能(VSCode Folder Sorter)を作ります。冒頭で書いた「api、core、repository、serviceの順に並ぶのが許せない」問題を、自分の手で解決します。

VSCodeのエクスプローラーは標準ではアルファベット順に表示されますが、レイヤードアーキテクチャのように処理の流れが決まっているプロジェクトでは直感と合いません。

// アルファベット順(デフォルト)
api/
core/
repository/
service/

// 処理の流れ順(理想)
api/         ← リクエスト受付
service/     ← ビジネスロジック
repository/  ← データアクセス
core/        ← 共通定義

この拡張機能はフォルダの表示順を自由に並び替えられるカスタムパネルをVSCodeに追加します。

アーキテクチャ

責務をTypeScriptとRust/Wasmで明確に分けています。

TypeScript(VSCode APIとの橋渡し)
├── TreeViewの描画・D&Dイベント処理
├── FileSystemWatcherによるフォルダ変更検知
├── ファイルシステムのスキャン(Node.js fs)
└── Wasmの呼び出し

Rust/Wasm(純粋なロジック)
├── スキャン結果とfolder-order.jsonのマージ・並び順適用
├── D&D操作後の順序更新
└── 新規フォルダのJSONへの追加

ポイントは「ファイルシステムへのアクセスはTypeScript側に任せる」点です。wasm32-unknown-unknown ターゲットでは std::fs が使えないため、Wasmは純粋な計算処理だけを担当します。

保存先:.vscode/folder-order.json

並び順はワークスペース内の .vscode/folder-order.json に保存します。

{
  "version": 1,
  "order": {
    ".": ["api", "service", "repository", "core"],
    "service": ["auth", "user", "payment"]
  }
}
  • キー:親フォルダのパス(ワークスペースルートは ".")
  • 値:子フォルダ名の配列(表示順)

Rustバックエンドの実装

Cargo.toml

[package]
name = "backend"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

型定義

TypeScriptとRustの間はすべてJSON文字列でやり取りします。

// scanner.rs
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TreeNode {
    pub name: String,
    pub path: String,    // ワークスペースルートからの相対パス
    pub is_dir: bool,
    pub children: Vec<TreeNode>,
}

// order_json.rs
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FolderOrder {
    pub version: u32,
    pub order: HashMap<String, Vec<String>>,
}

// merger.rs
#[derive(Deserialize, Debug)]
pub struct MoveOperation {
    pub parent: String,     // 親パス(ルートは '.')
    pub from_index: usize,
    pub to_index: usize,
}

Wasm公開API(lib.rs)

TypeScriptから呼び出せる関数は3つです。

// TypeScriptがスキャンしたTreeNode[]のJSONと、order.jsonを受け取って
// 順序を適用したTreeNode[]のJSONを返す
#[wasm_bindgen]
pub fn build_tree(scan_json: &str, order_json: &str) -> Result<String, JsValue> {
    let order = order_json::parse(order_json)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    let nodes: Vec<scanner::TreeNode> = serde_json::from_str(scan_json)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    let merged = merger::merge(nodes, &order);
    serde_json::to_string(&merged).map_err(|e| JsValue::from_str(&e.to_string()))
}

// D&D操作後に順序を更新したJSONを返す
#[wasm_bindgen]
pub fn update_order(order_json: &str, move_op_json: &str) -> Result<String, JsValue> {
    let mut order = order_json::parse(order_json)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    let move_op: merger::MoveOperation = serde_json::from_str(move_op_json)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    merger::apply_move(&mut order, &move_op)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    serde_json::to_string(&order).map_err(|e| JsValue::from_str(&e.to_string()))
}

// 新規フォルダをJSONの末尾に追加して返す
#[wasm_bindgen]
pub fn add_new_entry(order_json: &str, new_path: &str) -> Result<String, JsValue> {
    let mut order = order_json::parse(order_json)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    merger::append_entry(&mut order, new_path);
    serde_json::to_string(&order).map_err(|e| JsValue::from_str(&e.to_string()))
}

マージロジック(merger.rs)

merge() はTypeScriptがスキャンしたディレクトリツリーに対して、folder-order.json の順序を再帰的に適用します。

pub fn merge(nodes: Vec<TreeNode>, order: &FolderOrder) -> Vec<TreeNode> {
    merge_level(nodes, ".", order)
}

fn merge_level(nodes: Vec<TreeNode>, parent_key: &str, order: &FolderOrder) -> Vec<TreeNode> {
    let (dirs, files): (Vec<TreeNode>, Vec<TreeNode>) =
        nodes.into_iter().partition(|n| n.is_dir);

    let ordered_dirs = apply_dir_order(dirs, parent_key, order);

    let mut result: Vec<TreeNode> = ordered_dirs
        .into_iter()
        .map(|mut node| {
            let child_key = if parent_key == "." {
                node.name.clone()
            } else {
                format!("{}/{}", parent_key, node.name)
            };
            node.children = merge_level(node.children, &child_key, order);
            node
        })
        .collect();

    result.extend(files);
    result
}

order.json に定義されていないフォルダはアルファベット順で末尾に追加されます。また、order.json に書かれているが実際には存在しないフォルダは自動的に除外されます。

TypeScriptの実装

ファイル構成

ts/
├── extension.ts          ← エントリポイント
├── wasmBridge.ts         ← Wasmのラッパー
├── folderTreeProvider.ts ← TreeView + D&D
└── folderWatcher.ts      ← フォルダ追加/削除の監視

WasmBridge(wasmBridge.ts)

wasm-bindgen --target nodejs が生成したモジュールを require() でロードします。ファイルシステムのスキャンもここで行い、Wasmには純粋なデータだけを渡します。

export class WasmBridge {
    private wasm: WasmModule | null = null;

    async initialize(): Promise<void> {
        const pkgPath = path.join(this.context.extensionPath, 'wasm', 'backend.js');
        this.wasm = require(pkgPath) as WasmModule;
    }

    buildTree(workspacePath: string, orderJson: string): string {
        if (!this.wasm) { throw new Error('WASM not initialized'); }
        // ファイルシステムスキャンはNode.js側で行う(WASMはfs非対応)
        const scanJson = JSON.stringify(scanDirectory(workspacePath, workspacePath));
        return this.wasm.build_tree(scanJson, orderJson);
    }
}

function scanDirectory(dir: string, workspaceRoot: string): TreeNode[] {
    const entries = fs.readdirSync(dir, { withFileTypes: true });
    entries.sort((a, b) => a.name.localeCompare(b.name));

    return entries
        .filter(e => !e.name.startsWith('.'))
        .map(e => {
            const fullPath = path.join(dir, e.name);
            const relPath = path.relative(workspaceRoot, fullPath).replace(/\\/g, '/');
            const isDir = e.isDirectory();
            return {
                name: e.name,
                path: relPath,
                is_dir: isDir,
                children: isDir ? scanDirectory(fullPath, workspaceRoot) : [],
            };
        });
}

 

なぜスキャンをTypeScript側に移したか
wasm32-unknown-unknown ターゲットはRustの std::fs に対応していないため、Wasm内からファイルシステムにアクセスできません。スキャン処理をNode.js側に移すことで解決しています。

FolderTreeProvider(folderTreeProvider.ts)

VSCodeの TreeDataProviderTreeDragAndDropController を両方実装します。

 

export class FolderTreeProvider
    implements vscode.TreeDataProvider<TreeNode>, vscode.TreeDragAndDropController<TreeNode>
{
    readonly dropMimeTypes = ['application/vnd.code.tree.folderSorter'];
    readonly dragMimeTypes = ['application/vnd.code.tree.folderSorter'];

    private treeCache: TreeNode[] | null = null;
    // ...
}

TreeDataProvider はTreeViewの描画を担い、TreeDragAndDropController はD&Dを担います。同じクラスに両方実装して createTreeView に渡します。

vscode.window.createTreeView('folderSorterTree', {
    treeDataProvider: treeProvider,
    dragAndDropController: treeProvider,
});

treeCacheの役割

getChildren() はVSCodeがノードを展開するたびに呼ばれます。毎回Wasmを呼ぶのは無駄なので、最初の1回だけスキャン&Wasm呼び出しをして結果を treeCache に保存します。

null(初期)
  ↓ getChildren(undefined) でWasm呼び出し
TreeNode[](有効)
  ↓ D&D完了 / フォルダ追加削除 で refresh()
null(無効化) → 次のgetChildren()で再構築

D&Dの処理フロー

① handleDrag()
   └─ ドラッグ元のTreeNodeをDataTransferに "application/vnd.code.tree.folderSorter" キーで格納

② handleDrop()
   ├─ DataTransferからドラッグ元を取り出す
   ├─ 異なる階層へのドロップは無視(sourceParent !== targetParent)
   ├─ treeCacheから同階層のフォルダ一覧(siblings)を取得
   ├─ from_index / to_index を算出
   ├─ order.jsonを読み込み → siblingNamesで同期(現在の表示順をベースラインに確定)
   ├─ WasmBridge.updateOrder() でRustに移動を適用させる
   ├─ 新しいorder.jsonを書き込み
   └─ refresh() → treeCache破棄 → 再構築 → TreeView再描画

D&Dは同一階層内の並び替えのみ許可しています。フォルダを別の階層に移動する操作はVSCode標準のファイル操作に任せます。

FolderWatcher(folderWatcher.ts)

FileSystemWatcher で新規フォルダの作成・削除を監視します。

this.watcher = vscode.workspace.createFileSystemWatcher('**/*');
this.watcher.onDidCreate(this.onDidCreate, this);
this.watcher.onDidDelete(this.onDidDelete, this);

 

onDidCreate はファイル作成時にも発火するため、fs.statSync でディレクトリかどうか確認してからフォルダのみ処理します。新規フォルダが追加されたら add_new_entry でorder.jsonの末尾に追加し、ユーザーに通知します。

新規フォルダ作成
  ↓ onDidCreate()
  ├─ fs.statSync() でディレクトリ確認(ファイルは無視)
  ├─ WasmBridge.addNewEntry() でorder.jsonの末尾に追加
  ├─ showInformationMessage() でユーザーに通知
  └─ refresh() → TreeView再描画

 

フォルダ削除時は merge() が存在しないエントリを自動除外するため、TypeScript側は refresh() を呼ぶだけです。

Wasmのビルドと拡張機能のパッケージ化

# Wasmビルド(backendディレクトリで実行)
wasm-pack build --target nodejs --out-dir ../wasm

# TypeScriptコンパイル
npm run compile

# デバッグ実行
# → F5キーでExtension Development Hostを起動

# VSIXパッケージ作成
npx @vscode/vsce package

# インストール(コマンド)
code --install-extension folder-sorter-0.1.0.vsix

# インストール(GUI)
# 拡張機能タブ(Ctrl+Shift+X)右上の「…」→「VSIXからインストール...」→ vsixファイルを選択
初期状態
フォルダ移動後(wasmフォルダをclick-wasmの上部へ移動)

まとめ

今回の構成では、責務を以下のように分けました。

役割 担当
VSCode APIとのやり取り TypeScript
ファイルシステムのスキャン TypeScript(Node.js fs)
D&Dイベント・TreeView描画 TypeScript
フォルダ変更の監視 TypeScript(FileSystemWatcher)
並び順のマージ・更新ロジック Rust/Wasm

RustをWasmとして呼び出す構成の最大のポイントは「Wasmはファイルシステムにアクセスできない」という制約です。これを意識して責務を分けることで、RustはJSON操作という純粋な計算処理に集中でき、テストもしやすくなります。

「api、core、repository、service」の並びにイラっとしてから、ここまで来てようやく自分の理想の順番でフォルダを眺められるようになりました。皆さんも普段の業務での小さな不満を見つけたら、ぜひ自分で拡張機能を作ってみてください。Rust + Wasmの組み合わせは、その「ちょっとした不満を解決するツール」を作るのにかなり相性が良いと感じています。次はWasmとWebアプリケーションの組み合わせを試してみたいと思います。

ここまでご覧いただきありがとうございました。

このページの先頭へ