VSCodeで「選択行による一括置換」を実現するTypeScriptマクロ【テキスト編集が10倍速くなる】

Development
スポンサーリンク
スポンサーリンク

はじめに

テキスト編集で「複数の文字列を一度に置換したい」と思ったことはありませんか?

例えば、こんなシーン:

  • 変数名を複数箇所で一括変更
  • テーブル定義のデータ型を複数列で一括更新
  • ドキュメント内の用語統一を複数ワードで一括実施

VSCodeの標準機能では「1つずつ置換」が限界。複数の置換ペアを一度に処理するには、拡張機能や外部ツールが必要でした。

そこで、サクラエディタで10年以上愛用してきた「選択行一括置換マクロ」をTypeScriptで再実装しました。

この記事で分かること

  • 選択行一括置換マクロの機能と使い方
  • リテラル置換と正規表現置換の使い分け
  • TypeScript実装の詳細コード
  • 実務での活用例
  • トラブルシューティング

機能の概要

できること

選択した行から置換ペアを読み取り、指定範囲を一括置換

置換指示行(選択する):
19: aaa	111
20: bbb	222
21: ccc	333

↓ Ctrl+Alt+T で実行

置換対象範囲(21行目以降):
22: テスト aaa です
23: bbb もあります
24: ccc も置換されます

↓ 結果

22: テスト 111 です
23: 222 もあります
24: 333 も置換されます

2つの置換モード

  1. リテラル置換(Ctrl+Alt+T)

    • 文字列をそのまま検索・置換
    • 特殊文字もそのまま扱う
    • 安全で確実
  2. 正規表現置換(Ctrl+Shift+Alt+T)

    • パターンマッチングが可能
    • 後方参照・グループ化対応
    • 高度な置換に対応

終了位置の指定

デフォルトは「EOF」(ファイル末尾まで)ですが、以下も指定可能:

  • 文字列指定:その文字列を含む行の1行前まで
  • EOF:ファイル末尾まで
  • ---:区切り線まで
  • :三角マークまで

実務での活用例

例1:SQL文のテーブル名一括変更

置換指示行:

old_users	new_users
old_posts	new_posts
old_comments	new_comments

置換対象:

SELECT * FROM old_users WHERE ...;
UPDATE old_posts SET ...;
DELETE FROM old_comments WHERE ...;

結果:

SELECT * FROM new_users WHERE ...;
UPDATE new_posts SET ...;
DELETE FROM new_comments WHERE ...;

例2:Markdown用語統一

置換指示行:

サーバー	サーバ
データベース	DB
ブラウザー	ブラウザ

1行に1つずつ置換ペアを定義するだけで、ドキュメント全体を一括修正!

例3:変数名リファクタリング(正規表現)

置換指示行:

user_(\w+)	customer_$1
old_(\w+)_id	new_$1_id

置換対象:

const user_name = "John";
const user_age = 30;
const old_order_id = 123;

結果:

const customer_name = "John";
const customer_age = 30;
const new_order_id = 123;

正規表現の後方参照も完全対応!

例4:設定ファイルの値一括変更

置換指示行:

localhost	production.example.com
:8080	:443
http://	https://

開発環境から本番環境への設定変更が一瞬で完了。

例5:HTMLタグの一括変更

置換指示行:

<div	<section
</div>	</section>
<span	<strong
</span>	</strong>

セマンティックHTMLへのリファクタリングが簡単に。

サクラエディタからの移植経緯

サクラエディタ時代の実績

この機能、実は10年以上前からサクラエディタで愛用していた自作マクロです。

元記事:【サクラエディタ】1ファイル内一括置換マクロ【画像サンプルあり】

当時の課題:

  • VSCodeとサクラエディタを行き来するのが面倒
  • Git統合やリモート開発ではVSCodeが必須
  • でも一括置換はサクラエディタでないとできない

VSCode移植の決断

VSCodeで開発する機会が増えたため、「どうせなら全部VSCodeで完結させたい」と思い、TypeScriptで再実装を決意。

移植のメリット:

  • エディタ統一による効率化
  • TypeScriptの型安全性
  • Git管理による履歴保持
  • 正規表現モードの追加

TypeScript実装の詳細

コード全体

import * as vscode from 'vscode';

/**
 * 終了位置のデフォルト値を決定するタイプ
 */
enum EndMarkerType {
    EOF = 1,      // "EOF" (ファイル末尾)
    Separator = 2, // "---" (区切り線)
    Triangle = 3   // "▲" (三角マーク)
}

/**
 * 置換モード
 */
enum ReplaceMode {
    Literal,  // リテラル一致
    Regex     // 正規表現
}

/**
 * 選択行の「置換前\t置換後」ペアを使って、現在行から指定位置まで置換(リテラル)
 */
export async function replaceBySelectionLiteral() {
    await replaceBySelection(ReplaceMode.Literal, EndMarkerType.EOF);
}

/**
 * 選択行の「置換前\t置換後」ペアを使って、現在行から指定位置まで置換(正規表現)
 */
export async function replaceBySelectionRegex() {
    await replaceBySelection(ReplaceMode.Regex, EndMarkerType.EOF);
}

/**
 * メイン処理:選択行から置換ペアを抽出し、指定範囲を置換
 */
async function replaceBySelection(mode: ReplaceMode, defaultMarkerType: EndMarkerType) {
    const editor = vscode.window.activeTextEditor;
    if (!editor) {
        vscode.window.showWarningMessage('アクティブなエディタがありません');
        return;
    }

    const document = editor.document;
    const selection = editor.selection;
    
    // 選択範囲がない場合はエラー
    if (selection.isEmpty) {
        vscode.window.showWarningMessage('置換指示行を選択してください');
        return;
    }

    // 選択範囲の開始行・終了行を取得(0-based)
    const selectionStartLine = selection.start.line;
    const selectionEndLine = selection.end.line;
    
    // 置換開始行 = 選択範囲の次の行
    const replaceStartLine = selectionEndLine + 1;

    // 選択範囲の下に置換対象行がない場合はエラー
    if (replaceStartLine >= document.lineCount) {
        vscode.window.showInformationMessage('置換対象行がありません');
        return;
    }

    // 選択行から置換ペアを抽出
    const pairs = collectReplacePairs(document, selectionStartLine, selectionEndLine);
    if (pairs.length === 0) {
        vscode.window.showWarningMessage('タブ区切りの置換指定行がありません');
        return;
    }

    // 終了位置を決定
    const endLine = await promptForEndLine(document, replaceStartLine, defaultMarkerType);
    if (endLine === null) {
        vscode.window.showInformationMessage('操作がキャンセルされました');
        return;
    }

    if (endLine <= replaceStartLine) {
        vscode.window.showInformationMessage('終了位置が開始行と同じか手前のため、置換は行いません');
        return;
    }

    // 確認ダイアログ
    const modeText = mode === ReplaceMode.Literal ? 'リテラル' : '正規表現';
    const answer = await vscode.window.showInformationMessage(
        `${pairs.length}行のタブ区切り置換指定(${modeText})で\n` +
        `「${replaceStartLine + 1}行目 ~ ${endLine}行目」を置換します。\n` +
        `よろしいですか?`,
        { modal: true },
        'はい', 'いいえ'
    );

    if (answer !== 'はい') {
        return;
    }

    // 置換実行
    await applyReplacements(editor, pairs, replaceStartLine, endLine, mode);

    vscode.window.showInformationMessage(
        `置換完了: ${pairs.length}種類の文字列を置換しました`
    );
}

/**
 * 選択範囲から「置換前\t置換後」ペアを抽出
 */
function collectReplacePairs(
    document: vscode.TextDocument,
    startLine: number,
    endLine: number
): Array<[string, string]> {
    const pairs: Array<[string, string]> = [];

    for (let i = startLine; i <= endLine; i++) {
        const lineText = document.lineAt(i).text;
        const tabIndex = lineText.indexOf('\t');

        if (tabIndex < 0) {
            continue; // タブがない行は無視
        }

        const before = lineText.substring(0, tabIndex);
        const after = lineText.substring(tabIndex + 1);

        if (!before) {
            continue; // 置換前が空の行は無視
        }

        pairs.push([before, after]);
    }

    return pairs;
}

/**
 * 終了位置の入力プロンプト
 */
async function promptForEndLine(
    document: vscode.TextDocument,
    startLine: number,
    markerType: EndMarkerType
): Promise {
    const lastLine = document.lineCount - 1;

    // デフォルト値を決定
    let defaultValue: string;
    switch (markerType) {
        case EndMarkerType.EOF:
            defaultValue = 'EOF';
            break;
        case EndMarkerType.Separator:
            defaultValue = '---';
            break;
        case EndMarkerType.Triangle:
            defaultValue = '▲';
            break;
        default:
            defaultValue = 'EOF';
    }

    const input = await vscode.window.showInputBox({
        prompt: '終了位置を指定してください:',
        placeHolder: '文字列を指定: その文字列を含む行の1行上まで / 「EOF」: ファイル末尾まで / キャンセル: 処理中止',
        value: defaultValue
    });

    if (!input) {
        return null; // キャンセル
    }

    // EOFの場合
    if (input.toUpperCase() === 'EOF') {
        return lastLine + 1; // 最終行の次(全体を対象)
    }

    // 文字列検索(該当行の1行前まで)
    for (let i = startLine + 1; i <= lastLine; i++) {
        const lineText = document.lineAt(i).text;
        if (lineText.includes(input)) {
            return i; // 該当行の番号を返す(1行前まで置換)
        }
    }

    // 見つからない場合はEOF
    vscode.window.showInformationMessage(
        `「${input}」が見つからないため、ファイル末尾まで置換します`
    );
    return lastLine + 1;
}

/**
 * 実際に置換を適用
 */
async function applyReplacements(
    editor: vscode.TextEditor,
    pairs: Array<[string, string]>,
    startLine: number,
    endLine: number,
    mode: ReplaceMode
) {
    const document = editor.document;
    const lastLine = document.lineCount - 1;
    const actualEndLine = Math.min(endLine - 1, lastLine);

    await editor.edit(editBuilder => {
        for (const [before, after] of pairs) {
            if (!before || before === after) {
                continue; // 空文字列や同じ文字列はスキップ
            }

            // 対象範囲を行ごとに処理
            for (let lineNum = startLine; lineNum <= actualEndLine; lineNum++) {
                const line = document.lineAt(lineNum);
                const lineText = line.text;

                let newText: string;
                if (mode === ReplaceMode.Literal) {
                    // リテラル置換
                    newText = lineText.split(before).join(after);
                } else {
                    // 正規表現置換
                    try {
                        const regex = new RegExp(before, 'g');
                        newText = lineText.replace(regex, after);
                    } catch (e) {
                        // 正規表現エラーの場合はスキップ
                        console.error(`正規表現エラー: ${before}`, e);
                        continue;
                    }
                }

                // 変更があった場合のみ置換
                if (newText !== lineText) {
                    const range = new vscode.Range(
                        new vscode.Position(lineNum, 0),
                        new vscode.Position(lineNum, lineText.length)
                    );
                    editBuilder.replace(range, newText);
                }
            }
        }
    });
}

コードのポイント

1. 選択範囲の活用

const selection = editor.selection;
const selectionStartLine = selection.start.line;
const selectionEndLine = selection.end.line;
const replaceStartLine = selectionEndLine + 1;

選択範囲を置換指示として使い、その次の行から置換を開始。

2. タブ区切りのパース

const tabIndex = lineText.indexOf('\t');
const before = lineText.substring(0, tabIndex);
const after = lineText.substring(tabIndex + 1);

「置換前[Tab]置換後」形式を確実に解析。

3. モード分岐

if (mode === ReplaceMode.Literal) {
    newText = lineText.split(before).join(after);
} else {
    const regex = new RegExp(before, 'g');
    newText = lineText.replace(regex, after);
}

リテラルと正規表現を明確に分離。

4. 安全な正規表現処理

try {
    const regex = new RegExp(before, 'g');
    newText = lineText.replace(regex, after);
} catch (e) {
    console.error(`正規表現エラー: ${before}`, e);
    continue;
}

不正な正規表現でもスキップして続行。

package.jsonの設定

{
  "contributes": {
    "commands": [
      {
        "command": "myMacros.replaceBySelectionLiteral",
        "title": "Replace by Selection (Literal)",
        "category": "My Macros"
      },
      {
        "command": "myMacros.replaceBySelectionRegex",
        "title": "Replace by Selection (Regex)",
        "category": "My Macros"
      }
    ],
    "keybindings": [
      {
        "command": "myMacros.replaceBySelectionLiteral",
        "key": "ctrl+alt+t",
        "when": "editorTextFocus"
      },
      {
        "command": "myMacros.replaceBySelectionRegex",
        "key": "ctrl+shift+alt+t",
        "when": "editorTextFocus"
      }
    ]
  }
}

使い方

基本的な使い方

ステップ1:置換ペアを記述

old_name	new_name
old_value	new_value
old_key	new_key

ステップ2:選択する

置換ペアの行を選択(Shift+↓で複数行選択)

ステップ3:実行

  • リテラル置換:Ctrl+Alt+T
  • 正規表現置換:Ctrl+Shift+Alt+T

ステップ4:終了位置を指定

デフォルトの「EOF」のままEnter、または独自の文字列を入力

ステップ5:確認

置換内容を確認して「はい」をクリック

応用テクニック

範囲を限定する:

置換指示行(選択)
↓
置換対象範囲
↓
--- (ここで終了)
↓
置換しない範囲

終了位置に「---」と入力すれば、区切り線までのみ置換。

正規表現の後方参照:

(\w+)_old	$1_new
old_(\d+)	new_$1

キャプチャグループを活用した高度な置換が可能。

トラブルシューティング

Q: 「置換指示行を選択してください」と表示される

A: 置換ペアの行を選択していません

  • 置換ペアを記述した行を選択してから実行してください
  • 選択範囲が空の場合はこのエラーが出ます

Q: 「タブ区切りの置換指定行がありません」と表示される

A: タブ文字が入っていません

  • 「置換前[Tab]置換後」の形式で記述してください
  • スペースではなく、タブ文字(\t)を使ってください

Q: 正規表現が動かない

A: 正規表現モードで実行していますか?

  • リテラル:Ctrl+Alt+T
  • 正規表現:Ctrl+Shift+Alt+T
  • モードを確認してください

Q: 一部の置換がスキップされる

A: 正規表現エラーの可能性

  • コンソールログ(Ctrl+Shift+U → Extension Host)を確認
  • 正規表現の構文を見直してください

他のマクロとの組み合わせ

選択範囲コピーマクロとの連携

  1. 置換ペアをテンプレート化
  2. 空行までコピーマクロで複製
  3. 値だけ書き換えて実行

Git連携

# 置換前
git diff

# 置換実行
Ctrl+Alt+T

# 結果確認
git diff

# 問題なければコミット
git commit -am "Refactor: update variable names"

まとめ

VSCodeで複数の文字列を一括置換する機能、実装してみました。

このマクロの強み:

  • シンプルな操作(選択して実行)
  • タブ区切りの直感的な書式
  • リテラルと正規表現の両対応
  • 範囲指定の柔軟性
  • 確認ダイアログで安全

こんな人におすすめ:

  • テキスト編集の効率化したい
  • リファクタリングが多い
  • ドキュメント整備をよくする
  • サクラエディタから移行したい

サクラエディタで10年以上愛用してきた機能が、TypeScriptで現代的に蘇りました。

ぜひVSCodeでの快適なテキスト編集にお役立てください!

関連記事

  • サクラエディタからの移行経緯
サクラエディタからVSCodeへマクロ移行!快適開発環境の構築記録
はじめに長年愛用してきたサクラエディタのマクロ機能。便利なJavaScript/VBSマクロを多数作成して日常業務で活用してきましたが、最近のAWS開発やブログ執筆でVSCodeを使う機会が増えてきました。「VSCodeでもサクラエディタの...
  • TypeScriptマクロ開発環境の構築方法
VSCode TypeScriptマクロ開発環境の完全ガイド【セットアップから運用まで】
はじめにVSCodeでカスタムマクロを作成したいけど、どうやって開発環境を構築すればいいか分からない。そんな悩みを持つ方に向けて、TypeScriptでVSCode拡張機能を開発する環境の構築から、実際にマクロを作成して使えるようにするまで...
  • 空行までの一括選択・コピーマクロ
VSCodeで「任意行までの一括選択・コピー」を実現するTypeScriptマクロ開発【サクラエディタ移行の集大成】
はじめに:テキスト編集における「範囲選択の不便さ」VSCodeで長いログファイルやドキュメントを編集していて、「この行から特定の文字列まで一気にコピーしたい」と思ったことはありませんか?通常のVSCodeでは、以下のような手順が必要です:開...

タグ: #VSCode #TypeScript #マクロ #テキスト編集 #正規表現 #生産性向上 #サクラエディタ

コメント