1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルAI(Ollama CLI)で関数コメントを自動生成するVisual Studio拡張機能を作ってみた

1
Posted at

はじめに

コードにコメントを残すことは大切です。しかし、扱うコードが増えるほど、すべてのクラスやメソッドにコメントを記述する作業は大きな負担になります。

近年では、AIにコードコメントを生成してもらう方法も一般的になってきました。非常に便利な一方、クラウドAPIを利用する場合は、トークン消費や利用料金を考慮する必要があります。また、ソースコードを外部サービスへ送信したくないケースもあります。

そこで今回は、Ollamaを利用してローカルAI(LLM)を動かし、C#のXMLドキュメントコメントを自動生成するVisual Studio拡張機能を作成しました。

この記事では、拡張機能の構成と実装の流れを紹介します。

[NOTE]
本記事は実装例の紹介です。掲載するコードや設定値は、利用するモデル、PC環境、対象プロジェクトに合わせて調整してください。Ollama CLIのollamaコマンドを実行できるWindows環境を前提としています。

今回実装するもの

今回の目標は、次の2点です。

  • Ollamaを利用し、C#コードのXMLドキュメントコメントをローカルLLMで生成する
  • Visual Studioの右クリックメニューからコメント生成を実行できるようにする

処理全体の流れは、次のとおりです。

右クリックメニューからコマンドを実行
  ↓
対象ドキュメントを確認
  ↓
C#コードを解析
  ↓
Ollamaへコメント生成を依頼
  ↓
生成されたコメントを元のコードへ挿入
  ↓
使用したリソースを解放

動作環境

この記事では、次の環境で動作を確認しました。

  • Visual Studio 2022
  • C#
  • Ollama(CLIの ollama コマンドを利用)
  • Microsoft.CodeAnalysis.CSharp
  • ローカルで実行可能なLLM

また、動作確認に使用したPCのスペックは次のとおりです。

  • CPU:Intel Core i5-9500E
  • GPU:NVIDIA T-400(4GB)
  • RAM:48GB
  • OS:Windows 11 Enterprise

実装

事前準備

今回のツールを使用するには、Ollamaのインストールとモデルのダウンロードが必要です。

ここでは、Ollamaのインストール方法を説明します。

以降はWindowsでの手順です。macOSやLinuxでは一部の手順やコマンドが異なります。

  1. Ollamaの公式ページを開きます

  2. 「Download」ボタンを押し、使用するOS向けのOllamaインストーラーをダウンロードします

    Ollamaのダウンロード画面

  3. コマンドプロンプトを開き、次のコマンドを実行します。Ollamaのバージョンが表示されれば、インストールは完了です

    ollama -v
    

    Ollamaのバージョン確認

  4. 使用するAIモデルをダウンロードします。今回は**qwen2.5-coder:14b**を使用します
    このモデルを選定した理由としては、いくつかのモデルを試した結果、コメントの生成が比較的意図通りになりやすかったためです

    ollama pull qwen2.5-coder:14b
    
  5. 次のコマンドでOllamaに登録されているモデルの一覧を確認します。対象のモデルが表示されれば準備完了です

    ollama list
    

    Ollamaのモデル一覧

1. Visual Studio拡張機能プロジェクトを作成する

今回のツールは、Visual Studio内で操作を完結させることを目指します。まず、Visual Studio 2022で拡張機能用のプロジェクトを作成します。

  1. Visual Studioを起動します
  2. 「新しいプロジェクトの作成」を選択します

Visual Studioの新規プロジェクト作成画面

  1. 「VSIX」と検索し、Visual Studio拡張機能用のプロジェクトテンプレート「VSIX Project」を選択します。

[NOTE]
今回はC#で作成するため、C#のテンプレートを選択しました。

VSIX Projectテンプレートの選択画面

  1. プロジェクト名と保存先を設定します

プロジェクト名と保存先の設定画面

  1. プロジェクトを作成します

作成したVSIXプロジェクト

  1. 必要なSDKとパッケージをNuGetから導入します

補足: 以下は、掲載コードで直接使用する主なパッケージです。

パッケージ名 バージョン 用途
Microsoft.CodeAnalysis.CSharp 4.7.0 C#構文木の解析と編集
Microsoft.VisualStudio.SDK 17.0.32112.339 Visual Studio拡張機能開発SDK
Microsoft.VSSDK.BuildTools 17.14.2142 VSIXビルドツール

2. コマンドとメイン処理を実装する

拡張機能では、最初に右クリックメニューから呼び出すコマンドを登録します。登録したコマンドが選択されたときに、コメント生成処理を開始します。

メイン処理の流れは次のとおりです。

  1. アクティブなドキュメントを取得し、処理可能か確認する
  2. 対象のC#コードを解析する
  3. Ollamaを通してコメントを生成する
  4. 生成したコメントをコードへ挿入する
  5. 使用したリソースを解放する

注目ポイント
このコードでは、Visual Studioの右クリックメニューへコマンドを登録し、選択されたときにコメント生成処理を開始します。アクティブなドキュメントの取得、進捗ダイアログの表示、生成結果の反映までが主な処理です。Visual StudioのUIを操作するため、UIスレッドへ切り替えている点にも注目してください。

以下は、コマンド実行からコメント生成、コード反映までを制御するメイン処理です。初期化処理や補助メソッドを含むクラス全体は、記事末尾の「コード全体」で確認できます。

        /// <summary>
        /// コメント生成処理を非同期で実行する
        /// </summary>
        private async Task ExecuteAsync()
        {
            await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

            // 進捗ダイアログの取得
            var waitDialogFactory = await ServiceProvider.GetServiceAsync(typeof(SVsThreadedWaitDialogFactory)) as IVsThreadedWaitDialogFactory;
            IVsThreadedWaitDialog2 waitDialog2 = null;
            IVsThreadedWaitDialog3 waitDialog3 = null;

            try
            {

                // アクティブなドキュメントを取得
                var dte = await ServiceProvider.GetServiceAsync(typeof(DTE)) as DTE2;
                if (dte?.ActiveDocument == null)
                {
                    ShowMessage("アクティブなドキュメントがありません。", "エラー", OLEMSGICON.OLEMSGICON_WARNING);
                    return;
                }

                var activeDocument = dte.ActiveDocument;
                var textDocument = activeDocument.Object("TextDocument") as TextDocument;

                if (textDocument == null)
                {
                    ShowMessage("テキストドキュメントではありません。", "エラー", OLEMSGICON.OLEMSGICON_WARNING);
                    return;
                }

                // ドキュメント全体のテキストを取得
                var editPoint = textDocument.StartPoint.CreateEditPoint();
                var sourceCode = editPoint.GetText(textDocument.EndPoint);
                // 実行確認ダイアログ
                var confirmMessage = "コメント生成を開始します。\n\n続行しますか?";
                var confirmResult = VsShellUtilities.ShowMessageBox(
                    this.package,
                    confirmMessage,
                    "CommentGenerator",
                    OLEMSGICON.OLEMSGICON_QUERY,
                    OLEMSGBUTTON.OLEMSGBUTTON_YESNO,
                    OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);

                if (confirmResult != 6) // 6 = IDYES
                {
                    return;
                }

                OutputString("=== コメント生成開始 ===");

                // 進捗ダイアログを作成
                if (waitDialogFactory != null)
                {
                    waitDialogFactory.CreateInstance(out waitDialog2);
                    waitDialog3 = waitDialog2 as IVsThreadedWaitDialog3;

                    waitDialog3?.StartWaitDialogWithPercentageProgress(
                        szWaitCaption: "CommentGenerator",
                        szWaitMessage: "コメントを生成中です...",
                        szProgressText: "初期化中...",
                        varStatusBmpAnim: null,
                        szStatusBarText: "コメント生成中",
                        fIsCancelable: true,
                        iDelayToShowDialog: 0,
                        iTotalSteps: 100,
                        iCurrentStep: 0);
                }

                // 進捗コールバック(ダイアログ更新 + 出力ウィンドウ)
                var progress = new Progress<ProgressInfo>(info =>
                {
                    OutputString(info.Message);

                    if (waitDialog2 != null && info.Total > 0)
                    {
                        waitDialog2.UpdateProgress(
                            szUpdatedWaitMessage: "コメントを生成中です...",
                            szProgressText: info.Message,
                            szStatusBarText: info.Message,
                            iCurrentStep: info.Current,
                            iTotalSteps: info.Total,
                            fDisableCancel: false,
                            pfCanceled: out bool _);
                    }
                });

                // usingでCommentGeneratorServiceを確実にDispose
                using (var service = new CommentGeneratorService(
                    ollamaPath: "ollama",
                    model: "qwen2.5-coder:14b"))
                {
                    var generationResult = await service.GenerateCommentsForCodeAsync(sourceCode, progress);

                    // ダイアログを閉じてキャンセル状態を確認
                    if (waitDialog2 != null)
                    {
                        waitDialog2.EndWaitDialog(out int cancelledResult);
                        waitDialog2 = null;
                        waitDialog3 = null;

                        if (cancelledResult != 0)
                        {
                            OutputString("=== ユーザーによりキャンセルされました ===");
                            ShowMessage("処理がキャンセルされました。", "キャンセル", OLEMSGICON.OLEMSGICON_INFO);
                            return;
                        }
                    }

                    if (generationResult.Success)
                    {
                        // ドキュメント全体を置き換え
                        editPoint.Delete(textDocument.EndPoint);
                        editPoint.Insert(generationResult.ModifiedCode);

                        OutputString("=== コメント生成完了 ===");
                        ShowMessage(generationResult.Message, "完了", OLEMSGICON.OLEMSGICON_INFO);
                    }
                    else
                    {
                        OutputString("=== コメント生成失敗 ===");
                        ShowMessage(generationResult.Message, "エラー", OLEMSGICON.OLEMSGICON_WARNING);
                    }
                } // ここでCommentGeneratorServiceが自動的にDispose

            }
            catch (Exception ex)
            {
                OutputString($"=== エラー発生 ===\n{ex.Message}\n{ex.StackTrace}");
                ShowMessage($"エラーが発生しました: {ex.Message}", "エラー", OLEMSGICON.OLEMSGICON_CRITICAL);
            }
            finally
            {
                // ダイアログが残っていれば閉じる
                if (waitDialog2 != null)
                {
                    waitDialog2.EndWaitDialog(out int _);
                }
            }
        }

3. C#コードを解析してコメントを挿入する

コードの解析には、Microsoft.CodeAnalysis.CSharpを利用します。

今回の目的はC#のXMLドキュメントコメント生成なので、構文木からクラス、メソッド、プロパティを取得し、XMLドキュメントコメントが必要な要素を抽出します。その後、各要素の情報をOllamaへ渡し、返されたコメントを元のコードへ挿入します。

注目ポイント
ここでは、Roslynの構文木を使ってクラス、メソッド、プロパティを抽出し、XMLドキュメントコメントが付いていない要素を生成対象としてまとめます。後半のCommentInserterでは、対象要素の行番号をもとに構文ノードを探し、生成されたコメントを先頭へ追加しています。複数要素を処理する場合、先の挿入によって後続要素の行番号がずれるため、実際のサービス側では下の行から処理するか、挿入のたびに再解析する必要があります。

以下は、Roslynの構文木からコメント生成対象を抽出する中心部分です。補助メソッドとCodeElementの定義を含む全体は、記事末尾にまとめています。

        /// <summary>
        /// ソースコードを解析してコメントが必要な要素のリストを返す
        /// </summary>
        /// <param name="sourceCode">ソースコード</param>
        public List<CodeElement> AnalyzeCode(string sourceCode)
        {
            var elements = new List<CodeElement>();

            // SyntaxTreeは軽量で、Roslynが内部でキャッシュ管理するためDispose不要
            var tree = CSharpSyntaxTree.ParseText(sourceCode);
            var root = tree.GetRoot();

            // クラスの抽出
            var classes = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
            foreach (var classDecl in classes)
            {
                if (!HasDocumentationComment(classDecl))
                {
                    elements.Add(new CodeElement
                    {
                        Type = "クラス",
                        Name = classDecl.Identifier.Text,
                        Code = GetCodeSignature(classDecl),
                        LineNumber = classDecl.GetLocation().GetLineSpan().StartLinePosition.Line
                        // Nodeは保持しない(メモリリーク防止)
                    });
                }
            }

            // メソッドの抽出
            var methods = root.DescendantNodes().OfType<MethodDeclarationSyntax>();
            foreach (var method in methods)
            {
                if (!HasDocumentationComment(method))
                {
                    elements.Add(new CodeElement
                    {
                        Type = "メソッド",
                        Name = method.Identifier.Text,
                        Code = GetCodeSignature(method),
                        LineNumber = method.GetLocation().GetLineSpan().StartLinePosition.Line
                    });
                }
            }

            // プロパティの抽出
            var properties = root.DescendantNodes().OfType<PropertyDeclarationSyntax>();
            foreach (var property in properties)
            {
                if (!HasDocumentationComment(property))
                {
                    elements.Add(new CodeElement
                    {
                        Type = "プロパティ",
                        Name = property.Identifier.Text,
                        Code = property.ToString(),
                        LineNumber = property.GetLocation().GetLineSpan().StartLinePosition.Line
                    });
                }
            }

            return elements.OrderBy(e => e.LineNumber).ToList();
        }

以下は、生成されたXMLドキュメントコメントを対象ノードへ挿入する中心部分です。行番号の検索やインデント整形を含む全体は、記事末尾にまとめています。

        /// <summary>
        /// 指定されたコード要素の前にコメントを挿入する
        /// </summary>
        public string InsertComment(string sourceCode, CodeElement element, string comment)
        {
            // SyntaxTreeの作成
            var tree = CSharpSyntaxTree.ParseText(sourceCode);
            var root = tree.GetRoot();

            // 対象のノードを見つける
            var targetNode = FindNodeByLineNumber(root, element.LineNumber);

            if (targetNode == null)
            {
                return sourceCode;
            }

            // 既存のインデントを取得
            var leadingTrivia = targetNode.GetLeadingTrivia();
            var indentation = GetIndentation(leadingTrivia);

            // コメントをインデント付きで整形
            var formattedComment = FormatComment(comment, indentation);

            // コメントをトリビアとして追加
            var commentTrivia = SyntaxFactory.ParseLeadingTrivia(formattedComment + Environment.NewLine);
            var newNode = targetNode.WithLeadingTrivia(
                leadingTrivia.InsertRange(0, commentTrivia)
            );

            var newRoot = root.ReplaceNode(targetNode, newNode);
            var result = newRoot.ToFullString();

            return result;
        }

4. Ollamaクライアントを作成する

次に、Ollamaへリクエストを送信するクライアントを作成します。今回はHTTP APIではなく、Ollama CLIの ollama run を起動する方式です。

クライアントでは、主に次の処理を行います。

  • Ollama CLIのプロセスを起動する
  • 使用するモデルとプロンプトを指定する
  • 対象コードを送信する
  • 生成結果を受け取る
  • タイムアウトや実行エラーを処理する

注目ポイント
このクラスは、Ollama CLIの起動、モデルの存在確認、プロンプトの送信、生成結果の抽出を担当します。標準出力と標準エラー出力を別々に取得し、タイムアウトや終了コードも確認しています。また、モデルにXMLドキュメントコメントだけを返させるためのプロンプトを組み立てている点が中心です。

以下は、ollama runを起動して結果を受け取る中心部分です。モデル確認、プロンプト生成、応答抽出を含むクラス全体は、記事末尾にまとめています。

        /// <summary>
        /// Ollamaプロセスを起動してプロンプトを送信し、応答を取得する
        /// </summary>
        private async Task<string> RunOllamaAsync(string prompt)
        {
            Process process = null;

            try
            {
                var processStartInfo = new ProcessStartInfo
                {
                    FileName = ollamaPath,
                    Arguments = $"run {model}",
                    RedirectStandardInput = true,
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    UseShellExecute = false,
                    CreateNoWindow = true,
                    StandardOutputEncoding = Encoding.UTF8,
                    StandardErrorEncoding = Encoding.UTF8
                };

                var outputBuilder = new StringBuilder();
                var errorBuilder = new StringBuilder();

                process = new Process { StartInfo = processStartInfo };

                process.OutputDataReceived += (sender, e) =>
                {
                    if (!string.IsNullOrEmpty(e.Data))
                    {
                        outputBuilder.AppendLine(e.Data);
                    }
                };

                process.ErrorDataReceived += (sender, e) =>
                {
                    if (!string.IsNullOrEmpty(e.Data))
                    {
                        errorBuilder.AppendLine(e.Data);
                    }
                };

                process.Start();
                await process.StandardInput.WriteAsync(prompt);
                process.StandardInput.Close();
                process.BeginOutputReadLine();
                process.BeginErrorReadLine();

                // プロセスの完了を待機(タイムアウト付き:10分)
                var completed = await Task.Run(() => process.WaitForExit(600000));

                if (!completed)
                {
                    process.Kill();
                    throw new TimeoutException("Ollamaの応答がタイムアウトしました(10分経過)。");
                }

                if (process.ExitCode != 0)
                {
                    var errorMessage = errorBuilder.ToString();
                    throw new Exception($"Ollamaの実行に失敗しました(終了コード: {process.ExitCode})\n\nエラー:\n{errorMessage}");
                }

                return outputBuilder.ToString();
            }
            finally
            {
                if (process != null)
                {
                    try
                    {
                        if (!process.HasExited)
                        {
                            process.Kill();
                        }
                    }
                    catch { }

                    process.Dispose();
                }
            }
        }

今回はサンプルとして、コメント生成用のプロンプトをコード内に埋め込む想定です。ただし、実際に継続利用する場合は、プロンプトやモデル名を設定画面または外部ファイルから変更できるようにすると使いやすくなります。

なお、OllamaClientとCommentGeneratorServiceのコンストラクタ既定値は軽量なqwen2.5-coder:1.5bです。本記事で使用するqwen2.5-coder:14bは、CommentGenerateCommandからmodel引数で明示的に指定しています。

コード内で実際に使用しているプロンプトは、次のとおりです。

return $@"あなたはC#のXMLドキュメントコメント生成器です。
次の{elementType}に対するコメントを、自然な日本語(UTF-8)で生成してください。

厳守ルール:
- 出力は「///」で始まる行のみ。説明文、挨拶、コードブロック(```)は禁止。
- 説明文は日本語の常体で、1行で簡潔に書く。
- 説明文を二重引用符で囲まない。
- paramタグは引数ごとに1つ。name属性にはコード内の引数名(半角英数字)をそのまま書き、日本語の説明をタグの中に書く。
- 引数がなければparamタグは出力しない。
- 戻り値がvoidまたはコンストラクタ、クラス、プロパティの場合はreturnsタグを出力しない。
- 文字化けする記号や特殊文字は使わない。

コード:
{code}

出力例:
/// <summary>
/// ユーザー名からユーザーを検索する
/// </summary>
/// <param name=""userName"">検索するユーザー名</param>
/// <returns>見つかったユーザー。存在しない場合はnull</returns>

上記の形式でコメントのみを出力してください。";

AIの応答をそのまま挿入するのではなく、期待した形式になっているかを検証してから反映することも重要です。今回も、プロンプトでは「丁寧語等は使用せず」と指定していますが、実際には「表示します。」「出力します。」のような丁寧語が生成されるケースがありました。これは「期待した形式にならない」例の一つです。

動作結果

各処理を組み合わせることで、Visual Studioの右クリックメニューからコメント生成を実行できるようになりました。

対象のC#コード上で右クリックし、メニューに追加された「コメント生成(Ollama)」を選択すると、ローカルAIがコードを解析し、生成したコメントを対象コードへ挿入します。

右クリックメニューからコメント生成を実行

実行前

注目ポイント
実行前のコードにはXMLドキュメントコメントがありません。ここでは、コメント生成の対象となる2つのメソッドを示しています。

        private void ShowMessage(string message, string title, OLEMSGICON icon)
        {
            ThreadHelper.ThrowIfNotOnUIThread();
            VsShellUtilities.ShowMessageBox(
                this.package,
                message,
                title,
                icon,
                OLEMSGBUTTON.OLEMSGBUTTON_OK,
                OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
        }

        private void OutputString(string message)
        {
            ThreadHelper.ThrowIfNotOnUIThread();
            var outputWindow = Package.GetGlobalService(typeof(SVsOutputWindow)) as IVsOutputWindow;
            if (outputWindow != null)
            {
                var generalPaneGuid = Microsoft.VisualStudio.VSConstants.GUID_OutWindowGeneralPane;
                outputWindow.GetPane(ref generalPaneGuid, out IVsOutputWindowPane pane);
                pane?.OutputStringThreadSafe($"[CommentGenerator] {message}" + Environment.NewLine);
            }
        }

実行後

注目ポイント
実行後は、各メソッドの直前にsummaryとparamを含むXMLドキュメントコメントが追加されています。以下は貼り付け時のインデントと空行を修正した例で、メソッド本体と既存の書式は変更していません。

/// <summary>
/// メッセージボックスを表示します。
/// </summary>
/// <param name="message">表示するメッセージの内容</param>
/// <param name="title">メッセージボックスのタイトル</param>
/// <param name="icon">メッセージボックスに表示するアイコンの種類</param>
private void ShowMessage(string message, string title, OLEMSGICON icon)
{
    ThreadHelper.ThrowIfNotOnUIThread();
    VsShellUtilities.ShowMessageBox(
        this.package,
        message,
        title,
        icon,
        OLEMSGBUTTON.OLEMSGBUTTON_OK,
        OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
}

/// <summary>
/// 文字列を出力します。
/// </summary>
/// <param name="message">出力する文字列。</param>
private void OutputString(string message)
{
    ThreadHelper.ThrowIfNotOnUIThread();
    var outputWindow = Package.GetGlobalService(typeof(SVsOutputWindow)) as IVsOutputWindow;
    if (outputWindow != null)
    {
        var generalPaneGuid = Microsoft.VisualStudio.VSConstants.GUID_OutWindowGeneralPane;
        outputWindow.GetPane(ref generalPaneGuid, out IVsOutputWindowPane pane);
        pane?.OutputStringThreadSafe($"[CommentGenerator] {message}" + Environment.NewLine);
    }
}

今回のPC環境で実行できるモデルでは、生成結果が期待した形式にならないことがありました。出力品質は使用するモデルやプロンプトによって変わるため、モデルの変更やプロンプトの調整によって改善する可能性があります。

生成時間はPCのスペックや使用するモデルに左右されます。

まとめ

本記事では、Ollamaを利用してC#のXMLドキュメントコメントを生成するVisual Studio拡張機能の構成を紹介しました。

コードコメントは保守性を高めるうえで重要ですが、コード量が増えるほど記述の負担も大きくなります。ローカルLLMを活用すれば、クラウドAPIのトークン消費を抑えながら、コメント作成を支援できます。また、処理をローカル環境内で完結させることで、ソースコードを外部のAIサービスへ送信せずに利用できます。ただし、利用にあたっては所属組織のセキュリティポリシーを確認してください。

まだ改善できる点はありますが、コメント作成の負担を軽減する方法の一つとして参考になれば幸いです。

コード全体

ここまでの各節では、処理の要点が分かるコードだけを抜粋しました。クラス全体を確認したい場合は、以下の項目を展開してください。

補足: 本記事では実装の一部を省略しています。以下に掲載するのは、記事内で紹介した主要クラスの全体です。Packageクラス、CommentGeneratorService、ProgressInfo、メニュー登録用の.vsctなどは含みません。

CommentGenerateCommand.csを表示する
using EnvDTE;
using EnvDTE80;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using System;
using System.ComponentModel.Design;
using System.Threading.Tasks;

namespace CommentGeneratorFromOllama
{
    /// <summary>
    /// コメント生成コマンドを処理する
    /// </summary>
    internal sealed class CommentGenerateCommand
    {
        /// <summary>
        /// コマンドID
        /// </summary>
        public const int CommandId = 0x0100;

        /// <summary>
        /// コマンドメニューグループのGUID
        /// </summary>
        public static readonly Guid CommandSet = new Guid("5aefa4f7-644e-49be-80c8-02273dc6a8ab");

        /// <summary>
        /// このコマンドを提供するVS Package
        /// </summary>
        private readonly AsyncPackage package;

        /// <summary>
        /// <see cref="CommentGenerateCommand"/>クラスの新しいインスタンスを初期化し、
        /// メニューにコマンドハンドラーを登録する
        /// </summary>
        /// <param name="package">このコマンドを所有するパッケージ</param>
        /// <param name="commandService">コマンドを登録するサービス</param>
        private CommentGenerateCommand(AsyncPackage package, OleMenuCommandService commandService)
        {
            this.package = package;
            if (package == null)
            {
                throw new ArgumentNullException(nameof(package));
            }

            if (commandService == null)
            {
                throw new ArgumentNullException(nameof(commandService));
            }

            var menuCommandID = new CommandID(CommandSet, CommandId);
            var menuItem = new MenuCommand(this.Execute, menuCommandID);
            commandService.AddCommand(menuItem);
        }

        /// <summary>
        /// コマンドのインスタンスを取得する
        /// </summary>
        public static CommentGenerateCommand Instance
        {
            get;
            private set;
        }

        /// <summary>
        /// 所有パッケージからサービスプロバイダーを取得する
        /// </summary>
        private Microsoft.VisualStudio.Shell.IAsyncServiceProvider ServiceProvider
        {
            get
            {
                return this.package;
            }
        }

        /// <summary>
        /// コマンドのシングルトンインスタンスを初期化する
        /// </summary>
        /// <param name="package">このコマンドを所有するパッケージ</param>
        public static async Task InitializeAsync(AsyncPackage package)
        {
            // Switch to the main thread - the call to AddCommand in CommentGenerateCommand's constructor requires
            // the UI thread.
            await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);

            OleMenuCommandService commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService;
            Instance = new CommentGenerateCommand(package, commandService);
        }

        /// <summary>
        /// メニュー項目がクリックされたときにコマンドを実行する
        /// </summary>
        /// <param name="sender">イベントの送信元</param>
        /// <param name="e">イベントデータ</param>
        private void Execute(object sender, EventArgs e)
        {
            ThreadHelper.ThrowIfNotOnUIThread();

            // 非同期処理を開始
            _ = ExecuteAsync();
        }

        /// <summary>
        /// コメント生成処理を非同期で実行する
        /// </summary>
        private async Task ExecuteAsync()
        {
            await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

            // 進捗ダイアログの取得
            var waitDialogFactory = await ServiceProvider.GetServiceAsync(typeof(SVsThreadedWaitDialogFactory)) as IVsThreadedWaitDialogFactory;
            IVsThreadedWaitDialog2 waitDialog2 = null;
            IVsThreadedWaitDialog3 waitDialog3 = null;

            try
            {

                // アクティブなドキュメントを取得
                var dte = await ServiceProvider.GetServiceAsync(typeof(DTE)) as DTE2;
                if (dte?.ActiveDocument == null)
                {
                    ShowMessage("アクティブなドキュメントがありません。", "エラー", OLEMSGICON.OLEMSGICON_WARNING);
                    return;
                }

                var activeDocument = dte.ActiveDocument;
                var textDocument = activeDocument.Object("TextDocument") as TextDocument;

                if (textDocument == null)
                {
                    ShowMessage("テキストドキュメントではありません。", "エラー", OLEMSGICON.OLEMSGICON_WARNING);
                    return;
                }

                // ドキュメント全体のテキストを取得
                var editPoint = textDocument.StartPoint.CreateEditPoint();
                var sourceCode = editPoint.GetText(textDocument.EndPoint);
                // 実行確認ダイアログ
                var confirmMessage = "コメント生成を開始します。\n\n続行しますか?";
                var confirmResult = VsShellUtilities.ShowMessageBox(
                    this.package,
                    confirmMessage,
                    "CommentGenerator",
                    OLEMSGICON.OLEMSGICON_QUERY,
                    OLEMSGBUTTON.OLEMSGBUTTON_YESNO,
                    OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);

                if (confirmResult != 6) // 6 = IDYES
                {
                    return;
                }

                OutputString("=== コメント生成開始 ===");

                // 進捗ダイアログを作成
                if (waitDialogFactory != null)
                {
                    waitDialogFactory.CreateInstance(out waitDialog2);
                    waitDialog3 = waitDialog2 as IVsThreadedWaitDialog3;

                    waitDialog3?.StartWaitDialogWithPercentageProgress(
                        szWaitCaption: "CommentGenerator",
                        szWaitMessage: "コメントを生成中です...",
                        szProgressText: "初期化中...",
                        varStatusBmpAnim: null,
                        szStatusBarText: "コメント生成中",
                        fIsCancelable: true,
                        iDelayToShowDialog: 0,
                        iTotalSteps: 100,
                        iCurrentStep: 0);
                }

                // 進捗コールバック(ダイアログ更新 + 出力ウィンドウ)
                var progress = new Progress<ProgressInfo>(info =>
                {
                    OutputString(info.Message);

                    if (waitDialog2 != null && info.Total > 0)
                    {
                        waitDialog2.UpdateProgress(
                            szUpdatedWaitMessage: "コメントを生成中です...",
                            szProgressText: info.Message,
                            szStatusBarText: info.Message,
                            iCurrentStep: info.Current,
                            iTotalSteps: info.Total,
                            fDisableCancel: false,
                            pfCanceled: out bool _);
                    }
                });

                // usingでCommentGeneratorServiceを確実にDispose
                using (var service = new CommentGeneratorService(
                    ollamaPath: "ollama",
                    model: "qwen2.5-coder:14b"))
                {
                    var generationResult = await service.GenerateCommentsForCodeAsync(sourceCode, progress);

                    // ダイアログを閉じてキャンセル状態を確認
                    if (waitDialog2 != null)
                    {
                        waitDialog2.EndWaitDialog(out int cancelledResult);
                        waitDialog2 = null;
                        waitDialog3 = null;

                        if (cancelledResult != 0)
                        {
                            OutputString("=== ユーザーによりキャンセルされました ===");
                            ShowMessage("処理がキャンセルされました。", "キャンセル", OLEMSGICON.OLEMSGICON_INFO);
                            return;
                        }
                    }

                    if (generationResult.Success)
                    {
                        // ドキュメント全体を置き換え
                        editPoint.Delete(textDocument.EndPoint);
                        editPoint.Insert(generationResult.ModifiedCode);

                        OutputString("=== コメント生成完了 ===");
                        ShowMessage(generationResult.Message, "完了", OLEMSGICON.OLEMSGICON_INFO);
                    }
                    else
                    {
                        OutputString("=== コメント生成失敗 ===");
                        ShowMessage(generationResult.Message, "エラー", OLEMSGICON.OLEMSGICON_WARNING);
                    }
                } // ここでCommentGeneratorServiceが自動的にDispose

            }
            catch (Exception ex)
            {
                OutputString($"=== エラー発生 ===\n{ex.Message}\n{ex.StackTrace}");
                ShowMessage($"エラーが発生しました: {ex.Message}", "エラー", OLEMSGICON.OLEMSGICON_CRITICAL);
            }
            finally
            {
                // ダイアログが残っていれば閉じる
                if (waitDialog2 != null)
                {
                    waitDialog2.EndWaitDialog(out int _);
                }
            }
        }
        
        /// <summary>
        /// メッセージボックスを表示する。
        /// </summary>
        /// <param name="message">表示するメッセージ。</param>
        /// <param name="title">メッセージボックスのタイトル。</param>
        /// <param name="icon">表示するアイコンの種類。</param>
        private void ShowMessage(string message, string title, OLEMSGICON icon)
        {
            ThreadHelper.ThrowIfNotOnUIThread();
            VsShellUtilities.ShowMessageBox(
                this.package,
                message,
                title,
                icon,
                OLEMSGBUTTON.OLEMSGBUTTON_OK,
                OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
        }

        /// <summary>
        /// 文字列を出力する
        /// </summary>
        /// <param name="message">出力する文字列</param>
        private void OutputString(string message)
        {
            ThreadHelper.ThrowIfNotOnUIThread();
            var outputWindow = Package.GetGlobalService(typeof(SVsOutputWindow)) as IVsOutputWindow;
            if (outputWindow != null)
            {
                var generalPaneGuid = Microsoft.VisualStudio.VSConstants.GUID_OutWindowGeneralPane;
                outputWindow.GetPane(ref generalPaneGuid, out IVsOutputWindowPane pane);
                pane?.OutputStringThreadSafe($"[CommentGenerator] {message}" + Environment.NewLine);
            }
        }
    }
}

CodeAnalyzer.csを表示する
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace CommentGeneratorFromOllama
{
    /// <summary>
    /// コードを解析してコメントが必要な要素を抽出する
    /// </summary>
    public class CodeAnalyzer
    {
        /// <summary>
        /// ソースコードを解析してコメントが必要な要素のリストを返す
        /// </summary>
        /// <param name="sourceCode">ソースコード</param>
        public List<CodeElement> AnalyzeCode(string sourceCode)
        {
            var elements = new List<CodeElement>();

            // SyntaxTreeは軽量で、Roslynが内部でキャッシュ管理するためDispose不要
            var tree = CSharpSyntaxTree.ParseText(sourceCode);
            var root = tree.GetRoot();

            // クラスの抽出
            var classes = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
            foreach (var classDecl in classes)
            {
                if (!HasDocumentationComment(classDecl))
                {
                    elements.Add(new CodeElement
                    {
                        Type = "クラス",
                        Name = classDecl.Identifier.Text,
                        Code = GetCodeSignature(classDecl),
                        LineNumber = classDecl.GetLocation().GetLineSpan().StartLinePosition.Line
                        // Nodeは保持しない(メモリリーク防止)
                    });
                }
            }

            // メソッドの抽出
            var methods = root.DescendantNodes().OfType<MethodDeclarationSyntax>();
            foreach (var method in methods)
            {
                if (!HasDocumentationComment(method))
                {
                    elements.Add(new CodeElement
                    {
                        Type = "メソッド",
                        Name = method.Identifier.Text,
                        Code = GetCodeSignature(method),
                        LineNumber = method.GetLocation().GetLineSpan().StartLinePosition.Line
                    });
                }
            }

            // プロパティの抽出
            var properties = root.DescendantNodes().OfType<PropertyDeclarationSyntax>();
            foreach (var property in properties)
            {
                if (!HasDocumentationComment(property))
                {
                    elements.Add(new CodeElement
                    {
                        Type = "プロパティ",
                        Name = property.Identifier.Text,
                        Code = property.ToString(),
                        LineNumber = property.GetLocation().GetLineSpan().StartLinePosition.Line
                    });
                }
            }

            return elements.OrderBy(e => e.LineNumber).ToList();
        }

        /// <summary>
        /// すでにコメントがあるかどうか
        /// </summary>
        /// <param name="node">確認対象の構文ノード</param>
        /// <returns>XMLドキュメントコメントがある場合はtrue</returns>
        private bool HasDocumentationComment(SyntaxNode node)
        {
            var triviaList = node.GetLeadingTrivia();
            return triviaList.Any(t => t.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) ||
                                      t.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia));
        }

        /// <summary>
        /// コードのシグネチャを取得する
        /// </summary>
        /// <param name="node">対象の構文ノード</param>
        /// <returns>コードのシグネチャ</returns>
        private string GetCodeSignature(SyntaxNode node)
        {
            if (node is MethodDeclarationSyntax method)
            {
                // メソッドシグネチャのみを取得(本体は除外)
                return method.WithBody(null).WithExpressionBody(null).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)).ToString();
            }
            else if (node is ClassDeclarationSyntax classDecl)
            {
                // クラス定義の先頭部分のみ
                return classDecl.WithMembers(SyntaxFactory.List<MemberDeclarationSyntax>()).ToString();
            }
            return node.ToString();
        }
    }

    /// <summary>
    /// コード要素を表すクラス
    /// </summary>
    public class CodeElement
    {
        /// <summary>要素の種類</summary>
        public string Type { get; set; }

        /// <summary>要素名</summary>
        public string Name { get; set; }

        /// <summary>コメント生成に使用するコード</summary>
        public string Code { get; set; }

        /// <summary>ソースコード上の行番号</summary>
        public int LineNumber { get; set; }
        // Node プロパティを削除(SyntaxNodeの参照を保持しない)
    }
}

CommentInserter.csを表示する
using System;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

namespace CommentGeneratorFromOllama
{
    /// <summary>
    /// ソースコードにコメントを挿入する
    /// </summary>
    public class CommentInserter
    {
        /// <summary>
        /// 指定されたコード要素の前にコメントを挿入する
        /// </summary>
        public string InsertComment(string sourceCode, CodeElement element, string comment)
        {
            // SyntaxTreeの作成
            var tree = CSharpSyntaxTree.ParseText(sourceCode);
            var root = tree.GetRoot();

            // 対象のノードを見つける
            var targetNode = FindNodeByLineNumber(root, element.LineNumber);

            if (targetNode == null)
            {
                return sourceCode;
            }

            // 既存のインデントを取得
            var leadingTrivia = targetNode.GetLeadingTrivia();
            var indentation = GetIndentation(leadingTrivia);

            // コメントをインデント付きで整形
            var formattedComment = FormatComment(comment, indentation);

            // コメントをトリビアとして追加
            var commentTrivia = SyntaxFactory.ParseLeadingTrivia(formattedComment + Environment.NewLine);
            var newNode = targetNode.WithLeadingTrivia(
                leadingTrivia.InsertRange(0, commentTrivia)
            );

            var newRoot = root.ReplaceNode(targetNode, newNode);
            var result = newRoot.ToFullString();

            return result;
        }

        /// <summary>
        /// 行番号から対象の構文ノードを取得する
        /// </summary>
        /// <param name="root"> </param>
        /// <param name="lineNumber"> </param>
        private SyntaxNode FindNodeByLineNumber(SyntaxNode root, int lineNumber)
        {
            return root.DescendantNodes()
                .Where(n => n.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.ClassDeclaration) ||
                           n.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.MethodDeclaration) ||
                           n.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PropertyDeclaration))
                .FirstOrDefault(n => n.GetLocation().GetLineSpan().StartLinePosition.Line == lineNumber);
        }

        /// <summary>
        /// インデントを取得する
        /// </summary>
        /// <param name="triviaList"> </param>
        private string GetIndentation(SyntaxTriviaList triviaList)
        {
            var lastWhitespace = triviaList.LastOrDefault(t => t.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.WhitespaceTrivia));
            if (string.IsNullOrWhiteSpace(lastWhitespace.ToString()))
            {
                return string.Empty;
            }
            return lastWhitespace.ToString();
        }

        /// <summary>
        /// コメントをフォーマットする
        /// </summary>
        /// <param name="comment">フォーマットするコメント</param>
        /// <param name="indentation">適用するインデント</param>
        /// <returns>インデントを適用したコメント</returns>
        private string FormatComment(string comment, string indentation)
        {
            var lines = comment.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
            var formattedLines = new System.Collections.Generic.List<string>();

            foreach (var line in lines)
            {
                var trimmed = line.Trim();
                if (!string.IsNullOrEmpty(trimmed))
                {
                    formattedLines.Add(indentation + trimmed);
                }
            }

            return string.Join(Environment.NewLine, formattedLines);
        }
    }
}

OllamaClient.csを表示する
using System;
using System.Diagnostics;
using System.Text;
using System.Threading.Tasks;

namespace CommentGeneratorFromOllama
{
    /// <summary>
    /// Ollama CLIプロセスとの通信を管理するクライアント
    /// </summary>
    public class OllamaClient : IDisposable
    {
        private readonly string ollamaPath;
        private readonly string model;
        private bool isInitialized;
        private bool disposed;

        public OllamaClient(string ollamaPath = "ollama", string model = "qwen2.5-coder:1.5b")
        {
            this.ollamaPath = ollamaPath;
            this.model = model;
            isInitialized = false;
            disposed = false;
        }

        /// <summary>
        /// Ollamaプロセスを初期化する
        /// </summary>
        public async Task InitializeAsync()
        {
            if (isInitialized)
            {
                return;
            }

            // モデルがダウンロード済みか確認
            await EnsureModelExistsAsync();

            isInitialized = true;
        }

        /// <summary>
        /// 指定されたモデルが存在するか確認
        /// </summary>
        private async Task EnsureModelExistsAsync()
        {
            Process process = null;

            try
            {
                var checkProcess = new ProcessStartInfo
                {
                    FileName = ollamaPath,
                    Arguments = "list",
                    RedirectStandardOutput = true,
                    UseShellExecute = false,
                    CreateNoWindow = true
                };

                process = Process.Start(checkProcess);

                if (process != null)
                {
                    var output = await process.StandardOutput.ReadToEndAsync();
                    process.WaitForExit();

                    if (!output.Contains(model))
                    {
                        throw new Exception($"モデル '{model}' が見つかりません。\n\n以下のコマンドでダウンロードしてください:\nollama pull {model}");
                    }
                }
            }
            finally
            {
                process?.Dispose();
            }
        }

        /// <summary>
        /// コード要素に対してコメントを生成する
        /// </summary>
        public async Task<string> GenerateCommentAsync(string code, string elementType)
        {
            if (disposed)
            {
                throw new ObjectDisposedException(nameof(OllamaClient));
            }

            var prompt = BuildPrompt(code, elementType);
            var response = await RunOllamaAsync(prompt);
            return ExtractComment(response);
        }

        /// <summary>
        /// Ollamaプロセスを起動してプロンプトを送信し、応答を取得する
        /// </summary>
        private async Task<string> RunOllamaAsync(string prompt)
        {
            Process process = null;

            try
            {
                var processStartInfo = new ProcessStartInfo
                {
                    FileName = ollamaPath,
                    Arguments = $"run {model}",
                    RedirectStandardInput = true,
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    UseShellExecute = false,
                    CreateNoWindow = true,
                    StandardOutputEncoding = Encoding.UTF8,
                    StandardErrorEncoding = Encoding.UTF8
                };

                var outputBuilder = new StringBuilder();
                var errorBuilder = new StringBuilder();

                process = new Process { StartInfo = processStartInfo };

                process.OutputDataReceived += (sender, e) =>
                {
                    if (!string.IsNullOrEmpty(e.Data))
                    {
                        outputBuilder.AppendLine(e.Data);
                    }
                };

                process.ErrorDataReceived += (sender, e) =>
                {
                    if (!string.IsNullOrEmpty(e.Data))
                    {
                        errorBuilder.AppendLine(e.Data);
                    }
                };

                process.Start();
                await process.StandardInput.WriteAsync(prompt);
                process.StandardInput.Close();
                process.BeginOutputReadLine();
                process.BeginErrorReadLine();

                // プロセスの完了を待機(タイムアウト付き:10分)
                var completed = await Task.Run(() => process.WaitForExit(600000));

                if (!completed)
                {
                    process.Kill();
                    throw new TimeoutException("Ollamaの応答がタイムアウトしました(10分経過)。");
                }

                if (process.ExitCode != 0)
                {
                    var errorMessage = errorBuilder.ToString();
                    throw new Exception($"Ollamaの実行に失敗しました(終了コード: {process.ExitCode})\n\nエラー:\n{errorMessage}");
                }

                return outputBuilder.ToString();
            }
            finally
            {
                if (process != null)
                {
                    try
                    {
                        if (!process.HasExited)
                        {
                            process.Kill();
                        }
                    }
                    catch { }

                    process.Dispose();
                }
            }
        }

        /// <summary>
        /// プロンプトを構築する
        /// </summary>
        private string BuildPrompt(string code, string elementType)
        {
            return $@"あなたはC#のXMLドキュメントコメント生成器です。
次の{elementType}に対するコメントを、自然な日本語(UTF-8)で生成してください。

厳守ルール:
- 出力は「///」で始まる行のみ。説明文、挨拶、コードブロック(```)は禁止。
- 説明文は日本語の常体で、1行で簡潔に書く。
- 説明文を二重引用符で囲まない。
- paramタグは引数ごとに1つ。name属性にはコード内の引数名(半角英数字)をそのまま書き、日本語の説明をタグの中に書く。
- 引数がなければparamタグは出力しない。
- 戻り値がvoidまたはコンストラクタ、クラス、プロパティの場合はreturnsタグを出力しない。
- 文字化けする記号や特殊文字は使わない。

コード:
{code}

出力例:
/// <summary>
/// ユーザー名からユーザーを検索する
/// </summary>
/// <param name=""userName"">検索するユーザー名</param>
/// <returns>見つかったユーザー。存在しない場合はnull</returns>

上記の形式でコメントのみを出力してください。";
        }

        /// <summary>
        /// Ollamaの応答からコメント部分を抽出する
        /// </summary>
        private string ExtractComment(string response)
        {
            if (string.IsNullOrWhiteSpace(response))
            {
                return "/// <summary>\r\n/// [コメント生成失敗]\r\n/// </summary>";
            }

            var lines = response.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
            var commentLines = new System.Collections.Generic.List<string>();
            var inComment = false;

            foreach (var line in lines)
            {
                var trimmed = line.Trim();

                if (trimmed.StartsWith("///"))
                {
                    commentLines.Add(trimmed);
                    inComment = true;
                }
                else if (inComment)
                {
                    break;
                }
            }

            if (commentLines.Count > 0)
            {
                return string.Join(Environment.NewLine, commentLines);
            }

            var cleanResponse = response.Trim();
            if (cleanResponse.Length > 200)
            {
                cleanResponse = cleanResponse.Substring(0, 200) + "...";
            }

            return $"/// <summary>\r\n/// {cleanResponse}\r\n/// </summary>";
        }

        public void Dispose()
        {
            if (disposed)
            {
                return;
            }

            disposed = true;
            GC.SuppressFinalize(this);
        }
    }
}

参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?