SI企業に勤めるSEのブログです。

技術系、小説やドラマの感想、雑記など、雑多な日記です。

【技術】AmazonQ(Claude)でJavaのpropertiesファイル(URLエンコードあり)をVSCode上でネイティブに編集できるエディタを作ってみた

初めに

本記事はVSCodeが好きでJavaVSCodeでやりたいけど、古いアプリを保守しなくちゃならなくなって泣く泣くEclipseを使ってる、という方に向けて、今はやりの(今更感もあるけど)生成AIを学びがてら、propertiesファイルを扱うための拡張機能を開発してみた、という記事です。

注意:以下は未完成です。

作成したコードはこちらで公開しています。

github.com

書こうと思ったきっかけ

思った以上にVSCode統合のAmazonQの使い勝手、ClaudeSonet4の性能がよく、ぜひ紹介したい!と思いました。正直拡張機能はおまけです(笑)

作ろうと思ったきっかけ

まさに私が以下のような悩みを抱えていました。 * Eclipeseは重いしワークスペースの切り替えが面倒 * 構成が古臭い(好きな方すみません‼) * 並行してPythonのLambdaやNode.jsのプロジェクトも開発してるから、JavaのバックエンドのためだけにEclipseを使うのが面倒 * Eclipseは構成をファイルベースで受け渡すのに難があり、どうしても新規導入時にサポートが発生する(これは私の知識不足かも)

これを解決するため、できればVSCodeで全部まとめて、各ワークスペースの環境をDevcontainer化してしまいたい…てな思いを抱えてました。

とはいえ自分自身はVSCode拡張機能の作り方は知らないし、開発する役割からもだいぶ離れてレビュー側に回っているので、調べて作るのも大変だな…と思っていて放置していました。

もちろん、既存の拡張機能はありましたが、更新は2016年で止まっており、自分で試すだけならともかく、実業務の環境で導入するにはためらわれたので、自己責任で開発したほうがいいかなぁという気持ちもありました。

Qiitaとかを見ると自分で開発してる記事もあり、時間かかりそうだけど開発するか、、、と思ってVSCodeを開いたときに「AmazonQ」のメッセージが出てるのに気づき、「そういえばAmazonQって生成AI使えたよな、、、調べた限り制限はあるものの無料らしいし、ここ最近ずっと話題になってるから使ってみよう。」と思い立って開発してみたというわけです。

開発の記録

初期設定

AmazonQの利用については公式ドキュメントに記載がありますし、多くの導入記事があるので詳細は割愛しますが、以下の3ステップで完了です。

  1. VSCode拡張機能をインストール
  2. AWS Builder IDを作成
  3. VSCodeにアクセス許可

開発開始

以下の手順で開発をしていきます。

簡単なExtentionのサンプルを作成する

AmazonQのChat機能で以下のような指示を出すと、公式サンプルにあるような Hello World Extention を自動で作成してくれます。 なお、以下全編英語です。最初の表示が英語だったので…頑張って頭の中で英訳しながら指示を出しましたが、あとから調べたら日本語でも大丈夫だったっぽい(笑)

please make vscode extention.

機能を追加する

ほしい機能は決まっていたので、以下のように指示を出しました。

以下の機能を追加してください。
1. ファイルを保存した際にマルチバイト文字をエンコードしてください
2. ファイルを開いたときにマルチバイト文字をデコードしてください
3. Javaのpropertiesファイルのコードシンタックスをハイライトしてください

英文のプロンプト

add these features.
1. when file was saved, encode multibits charactors to url encoded charactors.
2. when file was opend, decode url encoded charactors to multibits charactors.
3. highlight the Java properties file code syntax.

とすると、デフォルトで作成した機能に加えて上記の機能をつけ足してくれましたので、最初のハローワールドの機能を除外してもらいました。

第一段階のコードとしては以下のようなものです。

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    let onSave = vscode.workspace.onDidSaveTextDocument((document) => {
        if (document.fileName.endsWith('.properties')) {
            const edit = new vscode.WorkspaceEdit();
            const text = document.getText();
            const encoded = text.replace(/[^\x00-\x7F]/g, (char) => 
                encodeURIComponent(char).replace(/%/g, '\\u').toLowerCase()
            );
            if (text !== encoded) {
                edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), encoded);
                vscode.workspace.applyEdit(edit);
            }
        }
    });

    let onOpen = vscode.workspace.onDidOpenTextDocument((document) => {
        if (document.fileName.endsWith('.properties')) {
            const text = document.getText();
            const decoded = text.replace(/\\u([0-9a-f]{2})/gi, (match, hex) => 
                decodeURIComponent('%' + hex)
            );
            if (text !== decoded) {
                const edit = new vscode.WorkspaceEdit();
                edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), decoded);
                vscode.workspace.applyEdit(edit);
            }
        }
    });

    context.subscriptions.push(onSave, onOpen);
}

export function deactivate() {}

これに加えて、文法のハイライト用のファイルを作成してくれます。(今回は割愛。) まあ普通に自分で書いたらこれだけで1時間くらいはかかりますが、大体20分くらいでここまで行きます。すごいを通り越して怖い。

動作確認

テストの方法を聞く

もちろんVSCodeでの動作確認方法なんて知らないので聞きます。

how to test this extention?

で、得られた回答の抜粋がこちら。(以下に加えてコンパイルの方法も教えてくれました。)

3. Test the extension:

  1. Open VS Code in the extension folder
  2. Press F5 - This launches Extension Development Host
  3. Create test file test.properties with content: name=こんにちは message=世界
  4. Test features:
    • Save: Characters should encode to \u format
    • Reopen: Characters should decode back to Japanese
    • Syntax highlighting: Properties should be colored

Quick test commands:

Creating a test properties file with multibyte characters to test the extension functionality

Testing steps:
1. Press F5 in VS Code
2. Open test.properties in the new window
3. Save the file - Japanese characters should convert to \u encoding
4. Close and reopen - should convert back to Japanese
5. Verify syntax highlighting works

The extension is ready to test!

うん。ばっちり。テストケースまで提案してくれるの?賢すぎない?

動作確認①

まあテストしてみるんですが、さすがに一発ではうまくいかない。以下のような問題がありましたので、順次修正させます。

  • 保存後に表示がASCIIエンコードされたものになってしまう
  • 保存後に開くたびに何も変更してないのに保存要求が出る

修正版①

修正内容としては、URLエンコードのタイミングを保存後から保存前に変更し、ファイルを書き換えて保存、拡張機能内で保存前後の情報を保持するようにして、保存時に比較して修正があるかを確認、という流れになっています。

import * as vscode from 'vscode';

const originalEncodedContent = new Map<string, string>();

export function activate(context: vscode.ExtensionContext) {
    let onSave = vscode.workspace.onWillSaveTextDocument((event) => {
        if (event.document.fileName.endsWith('.properties')) {
            const text = event.document.getText();
            const encoded = text.replace(/[^\x00-\x7F]/g, (char) => {
                const code = char.charCodeAt(0).toString(16).padStart(4, '0');
                return '\\u' + code;
            });
            
            const originalEncoded = originalEncodedContent.get(event.document.uri.toString());
            if (originalEncoded && originalEncoded === encoded) {
                return; // No actual changes made
            }
            
            if (text !== encoded) {
                const edit = new vscode.WorkspaceEdit();
                edit.replace(event.document.uri, new vscode.Range(0, 0, event.document.lineCount, 0), encoded);
                event.waitUntil(vscode.workspace.applyEdit(edit).then(() => {
                    // Decode back to readable characters after save
                    setTimeout(() => {
                        const savedText = event.document.getText();
                        const decoded = savedText.replace(/\\u([0-9a-f]{4})/gi, (match, hex) => 
                            String.fromCharCode(parseInt(hex, 16))
                        );
                        if (savedText !== decoded) {
                            const decodeEdit = new vscode.WorkspaceEdit();
                            decodeEdit.replace(event.document.uri, new vscode.Range(0, 0, event.document.lineCount, 0), decoded);
                            vscode.workspace.applyEdit(decodeEdit);
                        }
                    }, 100);
                }));
            }
            originalEncodedContent.set(event.document.uri.toString(), encoded);
        }
    });

    let onOpen = vscode.workspace.onDidOpenTextDocument((document) => {
        if (document.fileName.endsWith('.properties')) {
            const text = document.getText();
            const encoded = text.replace(/[^\x00-\x7F]/g, (char) => {
                const code = char.charCodeAt(0).toString(16).padStart(4, '0');
                return '\\u' + code;
            });
            originalEncodedContent.set(document.uri.toString(), encoded);
            
            const decoded = text.replace(/\\u([0-9a-f]{4})/gi, (match, hex) => 
                String.fromCharCode(parseInt(hex, 16))
            );
            if (text !== decoded) {
                const edit = new vscode.WorkspaceEdit();
                edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), decoded);
                vscode.workspace.applyEdit(edit);
            }
        }
    });

    context.subscriptions.push(onSave, onOpen);
}

export function deactivate() {
    originalEncodedContent.clear();
}

動作確認②

これでも完ぺきではありません。2回保存しないと変更マーカーが消えません。
コードから推測して、「ファイル保存後にエディターがスクリーンとファイルの差分を検知して未保存扱いになってるんじゃない?これって正しい?」と確認すると修正してくれました。

Please fix this.
When I save the file, I saw the encoded charactors and the edirots marker don't clear. So I must save the file 2 times.
I think the save event changes the file and screen, editor check diferencees the scereen and file, so editor mark the file has the unsaved changes. is that correct?

完成版

最終的に以下のようなコードで完成版となりました。 もともとはデコード/エンコード関数が重複して書かれてましたので、自分で少し手を入れています。

import * as vscode from 'vscode';

const originalEncodedContent = new Map<string, string>();
const fileExtension = '.properties';
function decodeText(text: String) {
    return text.replace(/\\u([0-9a-f]{4})/gi, (match, hex) =>
        String.fromCharCode(parseInt(hex, 16))
    );
}
function encodeText(text: String) {
    return text.replace(/[^\x00-\x7F]/g, (char) => {
        const code = char.charCodeAt(0).toString(16).padStart(4, '0');
        return '\\u' + code;
    });
}

export function activate(context: vscode.ExtensionContext) {
    let onSave = vscode.workspace.onWillSaveTextDocument((event) => {
        if (event.document.fileName.endsWith(fileExtension)) {
            const text = event.document.getText();
            const encoded = encodeText(text);
            const originalEncoded = originalEncodedContent.get(event.document.uri.toString());
            if (originalEncoded && originalEncoded === encoded) {
                return; // No actual changes made
            }
            
            if (text !== encoded) {
                const edit = new vscode.WorkspaceEdit();
                edit.replace(event.document.uri, new vscode.Range(0, 0, event.document.lineCount, 0), encoded);
                event.waitUntil(vscode.workspace.applyEdit(edit));
            }
            originalEncodedContent.set(event.document.uri.toString(), encoded);
        }
    });

    let onDidSave = vscode.workspace.onDidSaveTextDocument((document) => {
        if (document.fileName.endsWith(fileExtension)) {
            // Decode back to readable characters after save
            setTimeout(() => {
                const text = document.getText();
                const decoded = decodeText(text);
                if (text !== decoded) {
                    const edit = new vscode.WorkspaceEdit();
                    edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), decoded);
                    vscode.workspace.applyEdit(edit).then(() => {
                        // Mark document as saved to avoid unsaved changes marker
                        vscode.commands.executeCommand('workbench.action.files.save');
                    });
                }
            }, 50);
        }
    });

    let onOpen = vscode.workspace.onDidOpenTextDocument((document) => {
        if (document.fileName.endsWith('.properties')) {
            const text = document.getText();
            const encoded = encodeText(text);
            originalEncodedContent.set(document.uri.toString(), encoded);
            
            const decoded = decodeText(text);
            if (text !== decoded) {
                const edit = new vscode.WorkspaceEdit();
                edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), decoded);
                vscode.workspace.applyEdit(edit);
            }
        }
    });

    context.subscriptions.push(onSave, onDidSave, onOpen);
}
export function deactivate() {
    originalEncodedContent.clear();
}

最後の修整内容はClaude君に説明してもらったので、和訳したものを記載しておきます。

Claude君の回答

問題点

あなたの指摘どおりでした!実際にはこうなっていました:

  • ユーザーがファイルを保存 → VS Code が保存処理を開始
  • 拡張機能が文字をエンコード → こんにちは が \u3053\u3093... に変換される
  • そのままエンコードされたまま保存
  • 拡張機能が文字をデコード → \u3053\u3093... を こんにちは に戻す
  • VS Code がファイルとエディタの差異を検出 → 「未保存の変更」マークが出る
元のコードの問題点
let onSave = vscode.workspace.onWillSaveTextDocument((event) => {
    // 保存前に実行される
    // この中でエンコードし、さらにデコードまで試みていた
    // VS Code の保存管理を混乱させてしまっていた
});
今回行った修正

処理を2つのイベントに分離しました:

// イベント1:保存前 → エンコードのみ
let onSave = vscode.workspace.onWillSaveTextDocument((event) => {
    // ここではエンコードだけを実行
    // デコードはまだ行わない
});

// イベント2:保存後 → デコード処理
let onDidSave = vscode.workspace.onDidSaveTextDocument((document) => {
    // デコード処理を実行して元の文字に戻す
    // その後もう一度保存して、エディタとファイルの状態を同期
});
変更点の要点
以前(問題あり)
  • onWillSaveTextDocument:保存前に実行
  • 同じイベント内でエンコードもデコードもしていた
  • タイミングの競合が発生していた
現在(修正後)
  • onWillSaveTextDocument:エンコードだけを実行
  • onDidSaveTextDocument:保存が完了した後にデコード
  • vscode.commands.executeCommand('workbench.action.files.save') を追加して状態を同期
このように動作します:
  • 保存開始 → エンコードエンコードされたテキストが保存される
  • 保存完了 → デコード → エディタに可読な文字が表示される → 自動保存で同期
  • 「未保存の変更」マークが出なくなる! この修正は、エンコードとデコードを保存処理の異なるフェーズに分離しています。

終わりに

いや、これ便利過ぎないですか?そりゃ単純なプログラマいらない論争出てくるわ。というわけで、皆さんぜひ使ってみてください。

今後の課題

  1. パッケージしたファイルをGitに載せるのと、拡張機能ストアで公開にチャレンジする
  2. 果たしてこの組み方が最善なのか(特にファイルの内容を裏で持つ部分や保存マーカーを出すか判断するロジック)、もう少し自分で裏取する
  3. イベントの制御などは一次情報を見ておく
  4. 日本語でOKって覚える(笑)

便利だったこと

  • チャットの履歴をMarkdownで書き出してくれること
    • 会話した記録を記事に書き出すときにめちゃめちゃ便利でした。
  • 直接ワークスペースのファイルを書き換えてくれること
  • Chatウィンドウで直接コマンドを実行できること
    • 今回は割愛しましたが、Gitinitの作成とかGitコミットなんかも指示すればスクリプト生成して実行してくれます。コミットコメントまで生成してくれる…すごい…

不便だったこと

  • 一回VSCodeを閉じちゃうと、途中で実行したコマンド履歴が見えなくなってしまうこと