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)

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

終了位置の指定(改良版)

実行時にQuickPickダイアログが開き、以下の方法で終了位置を指定できます:

プリセット選択(↑↓キーで選択)

  • 空行: 次の空行の1つ前まで
  • EOF: ファイル末尾まで(デフォルト)
  • : 「▲」を含む行の1つ前まで
  • ▲▲▲: 「▲▲▲」を含む行の1つ前まで
  • ```: コードブロック終端の1つ前まで
  • ---: 区切り線の1つ前まで

直接入力も可能

プリセットを選ばずに、そのまま文字列を入力してEnterでもOK。例えば「TODO」と入力すれば、TODOを含む行の1つ前まで置換されます。

実務での活用例

例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管理による履歴保持
  • 正規表現モードの追加
  • QuickPickによる柔軟な入力

TypeScript実装の詳細

終了位置選択の実装(重要な改良点)

従来のshowInputBoxからcreateQuickPickに変更したことで、プリセット選択と直接入力の両方に対応しました。

/**
 * プリセット検索パターン
 */
const PRESET_PATTERNS = [
    { label: '空行', value: 'EMPTY_LINE', description: '次の空行まで' },
    { label: 'EOF', value: 'EOF', description: 'ファイル末尾まで' },
    { label: '▲', value: '▲', description: '▲が含まれる行の前まで' },
    { label: '▲▲▲', value: '▲▲▲', description: '▲▲▲が含まれる行の前まで' },
    { label: '```', value: '```', description: 'コードブロック終端の前まで' },
    { label: '---', value: '---', description: '区切り線の前まで' }
];

/**
 * 終了位置の選択ダイアログ(直接入力も可能)
 */
async function promptForEndLine(
    document: vscode.TextDocument,
    startLine: number
): Promise {
    const quickPick = vscode.window.createQuickPick();
    quickPick.items = PRESET_PATTERNS;
    quickPick.placeholder = '終了位置を選択または直接入力してください';
    quickPick.ignoreFocusOut = true;
    
    return new Promise((resolve) => {
        quickPick.onDidAccept(async () => {
            const selected = quickPick.selectedItems[0];
            const inputValue = quickPick.value.trim();
            
            let pattern: string | null = null;
            
            // 選択肢を選んだ場合
            if (selected) {
                const presetPattern = PRESET_PATTERNS.find(p => p.label === selected.label);
                pattern = presetPattern?.value || null;
            }
            // 直接入力した場合
            else if (inputValue) {
                pattern = inputValue;
            }
            
            quickPick.hide();
            
            if (!pattern) {
                resolve(null);
                return;
            }
            
            // 終了行を検索
            const endLine = await findEndLine(document, startLine, pattern);
            resolve(endLine);
        });
        
        quickPick.onDidHide(() => {
            resolve(null);
            quickPick.dispose();
        });
        
        quickPick.show();
    });
}

/**
 * 終了行を検索
 */
async function findEndLine(
    document: vscode.TextDocument,
    startLine: number,
    pattern: string
): Promise {
    const lastLine = document.lineCount - 1;
    
    // EOFの場合
    if (pattern === 'EOF') {
        return lastLine + 1; // 最終行の次(全体を対象)
    }
    
    // 空行の場合
    if (pattern === 'EMPTY_LINE') {
        for (let i = startLine + 1; i <= lastLine; i++) {
            const line = document.lineAt(i);
            if (line.isEmptyOrWhitespace) {
                return i; // 空行の番号を返す(1行前まで置換)
            }
        }
        
        // 空行が見つからない場合は最終行
        vscode.window.showInformationMessage('空行が見つからないため、ファイル末尾まで置換します');
        return lastLine + 1;
    }
    
    // 文字列検索(該当行の1行前まで)
    for (let i = startLine + 1; i <= lastLine; i++) {
        const lineText = document.lineAt(i).text;
        if (lineText.includes(pattern)) {
            return i; // 該当行の番号を返す(1行前まで置換)
        }
    }
    
    // 見つからない場合はEOF
    vscode.window.showInformationMessage(
        `「${pattern}」が見つからないため、ファイル末尾まで置換します`
    );
    return lastLine + 1;
}

その他の主要コード

置換ペアの抽出や実際の置換処理は従来通りです:

/**
 * 選択範囲から「置換前\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 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 (let lineNum = startLine; lineNum <= actualEndLine; lineNum++) {
            const line = document.lineAt(lineNum);
            let lineText = line.text;
            const originalText = lineText;

            // 全ペアを順番に適用
            for (const [before, after] of pairs) {
                if (!before || before === after) {
                    continue; // 空文字列や同じ文字列はスキップ
                }

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

            // 変更があった場合のみ置換(1行につき1回のreplaceでオーバーラップを回避)
            if (lineText !== originalText) {
                const range = new vscode.Range(
                    new vscode.Position(lineNum, 0),
                    new vscode.Position(lineNum, originalText.length)
                );
                editBuilder.replace(range, lineText);
            }
        }
    });
}

コードのポイント

1. QuickPickによる柔軟な入力

const quickPick = vscode.window.createQuickPick();
quickPick.items = PRESET_PATTERNS;
// プリセット選択 or 直接入力が可能

2. プリセットと直接入力の両対応

if (selected) {
    // プリセット選択
    pattern = presetPattern?.value || null;
} else if (inputValue) {
    // 直接入力
    pattern = inputValue;
}

3. タブ区切りのパース

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

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

4. オーバーラップエラーの回避(重要)

// 行ごとにループ(オーバーラップを防ぐため)
for (let lineNum = startLine; lineNum <= actualEndLine; lineNum++) {
    let lineText = line.text;
    
    // 全ペアを順番に適用
    for (const [before, after] of pairs) {
        lineText = lineText.split(before).join(after);
    }
    
    // 変更があった場合のみ1回だけreplace
    if (lineText !== originalText) {
        editBuilder.replace(range, lineText);
    }
}

同じ行に複数の置換対象がある場合でも、行ごとに全ペアを適用してから1回だけreplaceすることでオーバーラップエラーを回避。

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:終了位置を指定

QuickPickダイアログが開きます:

  • ↑↓でプリセットを選択してEnter
  • または直接文字列を入力して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)を確認
  • 正規表現の構文を見直してください

Q: 「Overlapping ranges are not allowed!」エラーが出る

A: 修正済みです

  • 最新版では、同じ行に複数の置換対象がある場合でも正常に動作します
  • git pullして最新版を取得してください

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

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

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

Git連携

# 置換前
git diff

# 置換実行
Ctrl+Alt+T

# 結果確認
git diff

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

まとめ

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

このマクロの強み:

  • シンプルな操作(選択して実行)
  • タブ区切りの直感的な書式
  • リテラルと正規表現の両対応
  • QuickPickによる柔軟な終了位置指定
  • プリセット選択と直接入力の両対応
  • 確認ダイアログで安全
  • オーバーラップエラーを回避

こんな人におすすめ:

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

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

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

関連記事

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

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

コメント