ISID テックブログ

ISIDが運営する技術ブログ

WPFでC#を実行するエディターを作成する

これは電通国際情報サービス Advent Calendar 2021の10日目の記事です。

はじめに

はじめまして!電通国際情報サービス(ISID) 製造ソリューション事業部の余部です。
構想設計支援システムiQUAVIS(アイクアビス)の開発を担当しています。

iQUAVISは自社でスクラッチ開発しているWPFアプリケーションです。機能を独自に拡張できるプラグインを作成するためのSDKとして、多くのAPIがあります。これを簡単にテストできるよう、アプリケーション上でC#を記述してAPIを実行できるエディターをテスト用のプラグインとして開発しました。これによってテスト効率が大幅に上がり、SDKの開発で欠かせない機能になっています。

今回は、WPFC#を実行するエディターを作成する方法を紹介します。

利用技術

開発環境

エディターの作成

UIコンポーネントを配置する

C#のコードを記述するTextBox、コードを実行するButton、実行結果を表示する読み取り専用TextBoxをXAMLで記述します。

<TextBox x:Name="CodeEditor" AcceptsReturn="True" />
<Button Click="Button_Click" Content="実行" />
<TextBox x:Name="ResultTextBox" IsReadOnly="True" TextWrapping="Wrap" />

※ Gridなどのレイアウト要素は省略しています。

エディターで記述したC#スクリプトを実行する

まず、Microsoft.CodeAnalysis.CSharp.Scripting のパッケージをインストールします。
次に、実行ボタンのクリックでMicrosoft.CodeAnalysis.CSharp.Scripting.CSharpScript.EvaluateAsyncを使用してC#スクリプトを実行し、結果をResultTextBoxに表示します。

private async void Button_Click(object sender, RoutedEventArgs e)
{
    var result = await CSharpScript.EvaluateAsync(CodeEditor.Text);
    ResultTextBox.Text = result?.ToString();
}

これで、CodeEditorに記述したC#を実行できるようになりました。
試しに、次のようなコードを実行しましょう。評価したい行には、末尾に ; を付けないでください。

System.Environment.Version

次のような結果が表示されます。

5.0.12

実行結果のオブジェクトをフォーマットする

先ほどの例では、実行結果の表示をToStringで文字列に変換しました。この場合はToStringを実装していないオブジェクトでは適切な結果が表示されません。
そこで、Microsoft.CodeAnalysis.CSharp.Scripting.Hosting.CSharpObjectFormatterを使ってフォーマットします。

ResultTextBox.Text = CSharpObjectFormatter.Instance.FormatObject(result);

次のようなコードを実行しましょう。

record struct Person(string Name, int Age);
new Person("Shohei", 27)

オブジェクトのデータがわかるようにフォーマットされました。

[Person { Name = Shohei, Age = 27 }]

ちなみに、record structC#10の構文です。Microsoft.CodeAnalysis.CSharp.Scriptingのバージョン4.0ではC#10に対応しているので、C#9のアプリケーションでもC#10の構文が使えます。次のページにRoslynで使用可能なC#のバージョンが記載されています。 https://github.com/dotnet/roslyn/blob/main/docs/wiki/NuGet-packages.md#versioning

スクリプトの実行例外を表示する

次のようなコードを実行しましょう。

string.Concat(null)

ArgumentNullExceptionがスローされてしまいます。実行例外をハンドリングしてResultTextBoxに表示しましょう。
コードを実行するCSharpScript.EvaluateAsyncが例外をスローするため、例外をキャッチすることもできますが、例外クラスを特定できないため、System.Exceptionをキャッチすることになります。そのような汎用的な例外のキャッチは避けたいところです。ここではMicrosoft.CodeAnalysis.Scripting.Script<T>.RunAsyncを使うことで、例外をスローせずに戻り値のScriptStateから例外を取得できます。

var state = await CSharpScript.Create(CodeEditor.Text).RunAsync(catchException: _ => true);
var formatter = CSharpObjectFormatter.Instance;
ResultTextBox.Text = state.Exception == null ?
    formatter.FormatObject(state.ReturnValue) :
    formatter.FormatException(state.Exception);

例外オブジェクトのフォーマットにも、先ほどのCSharpObjectFormatterが使えます。
これで、実行結果に例外を表示できるようになりました。先ほどのコードを再実行してみましょう。ResultTextBoxに例外の詳細が表示されます。

System.ArgumentNullException: Value cannot be null. (Parameter 'values')
  + string.Concat(string[])
  + <Initialize>.MoveNext()

例外の原因がわかりやすくなりましたね。

コンパイルエラーを表示する

次は、実行するコードがコンパイルエラーの場合を考慮しましょう。例えば、括弧が足りないコードを実行してみましょう。

string.Concat("a", "b"

Microsoft.CodeAnalysis.Scripting.CompilationErrorExceptionの例外がスローされてしまいます。先にCSharpScript.Createの結果をコンパイルすることで、例外をスローせずにコンパイルエラーを取得できます。

var script = CSharpScript.Create(CodeEditor.Text);

var diagnostics = script.Compile();
if (diagnostics.Any(x => x.Severity == DiagnosticSeverity.Error))
{
    ResultTextBox.Text = string.Join(Environment.NewLine, diagnostics);
    return;
}

var state = await script.RunAsync(catchException: _ => true);
...(省略)

先ほどのコードを再実行してみましょう。ResultTextBoxにコンパイルエラーの原因が表示されます。

(1,23): error CS1026: ) が必要です。

以上でコードを実行できるエディターができました。しかし、CodeEditorは単なるTextBoxのため、インテリセンスがありません。補完がないエディターでC#を書くのは辛いものです。
そこで、インテリセンスを実装しましょう。

.NET APIのインテリセンスを表示する

まず、RoslynPad.Editor.Windows のパッケージをインストールします。

現時点のRoslynPadの最新版RoslynPad.Editor.Windows 1.2.0ではRoslynの最新版に対応していないため、先にインストールしたMicrosoft.CodeAnalysis.CSharp.Scriptingを3.6に下げる必要があります。 RoslynPad.Editor.Windowsの依存関係でMicrosoft.CodeAnalysis.CSharp.Scriptingもインストールされるため、Microsoft.CodeAnalysis.CSharp.ScriptingをアンインストールしてもOKです。

次に、XAMLのTextBoxをRoslynCodeEditorに置き換えます。

<!--  RoslynPadのプレフィックスを定義する  -->
xmlns:roslyn="clr-namespace:RoslynPad.Editor;assembly=RoslynPad.Editor.Windows"
...(省略)
<roslyn:RoslynCodeEditor x:Name="CodeEditor" Loaded="CodeEditor_Loaded" />

RoslynCodeEditorはコードビハインドで初期化する必要があります。次のように、ロード時に初期化できます。

private void CodeEditor_Loaded(object sender, RoutedEventArgs e)
{
    var roslynPadAssemblies = new[]
    {
        Assembly.Load("RoslynPad.Roslyn.Windows"),
        Assembly.Load("RoslynPad.Editor.Windows")
    };

    var assemblies = new[]
    {
        Assembly.Load("System.Private.CoreLib")
    };

    var roslynHost = new RoslynHost(
        roslynPadAssemblies,
        RoslynHostReferences.NamespaceDefault.With(assemblyReferences: assemblies));

    CodeEditor.Initialize(roslynHost, new ClassificationHighlightColors(), Directory.GetCurrentDirectory(), string.Empty);
}

最初のRoslynPadアセンブリの設定は必須です。設定しないとRoslynCodeEditor.InitializeCompositionFailedExceptionが発生します。次のSystem.Private.CoreLibアセンブリは、インテリセンスで表示したいものを指定します。
これで、CoreLib APIのインテリセンスが表示されるようになりました。

先ほどのAssembly.Loadでは、アセンブリ名を文字列で指定しましたが、アセンブリ内に存在する任意のクラスを指定してタイプセーフでも記述できます。

var roslynPadAssemblies = new[]
{
    typeof(RoslynCodeEditor).Assembly, // RoslynPad.Editor.Windows
    typeof(GlyphExtensions).Assembly, // RoslynPad.Roslyn.Windows
};
var assemblies = new[]
{
    typeof(object).Assembly, // System.Private.CoreLib
};

自作したAPIのインテリセンスを表示し、実行する

次は、自作したAPIを実行できるようにします。例えば、次のようなクラスを作成します。

namespace Custom
{
    public static class Api
    {
        public static string Hello(string name) => $"Hello {name}!";
    }
}

これを実行できるようにするには、CSharpScript.Createアセンブリを指定する必要があります。インテリセンスも表示したいので、RoslynHostにも指定します。両者のアセンブリ設定を共通化するため、コンストラクターRoslynHostを生成してメンバーに保持するなどして、設定を共有します。

private readonly RoslynHost _roslynHost;

public MainWindow()
{
    ...(省略)
    var assemblies = new[]
    {
        typeof(object).Assembly
        Assembly.GetExecutingAssembly(), // 自作したAPIのアセンブリを設定する
    };
    _roslynHost = new RoslynHost(
        roslynPadAssemblies,
        RoslynHostReferences.NamespaceDefault.With(assemblyReferences: assemblies));
}

private void CodeEditor_Loaded(object sender, RoutedEventArgs e)
{
    CodeEditor.Initialize(_roslynHost, new ClassificationHighlightColors(), Directory.GetCurrentDirectory(), string.Empty);
}

次に、CSharpScript.Createアセンブリの設定を追加します。

var scriptOptions = ScriptOptions.Default.WithReferences(_roslynHost.DefaultReferences);
var script = CSharpScript.Create(CodeEditor.Text, scriptOptions);

これで、作成したCustom.Api.Helloが実行できるようになりました。

インスタンスメンバーを実行する

これまでは、静的メンバーを実行してきました。CodeEditorから内部で保持しているインスタンスにアクセスできると、さらに便利になります。ここでは、内部で利用しているRoslynHostのメンバーを実行できるようにします。
CSharpScriptでは、スクリプトグローバル変数としてアクセスできるインスタンスを設定できます。次のように、CSharpScript.Createインスタンスの型を、Script.RunAsyncインスタンスを設定します。

var script = CSharpScript.Create(CodeEditor.Text, scriptOptions, _roslynHost.GetType());
...(省略)
var state = await script.RunAsync(_roslynHost, _ => true);

インテリセンスに表示するのは、少々やっかいです。次のようにRoslynHostを継承したクラスを作成し、CreateProjectのオーバーライドでインスタンスの型を含んだプロジェクトを追加する必要があります。

public class CustomRoslynHost : RoslynHost
{
    private readonly Type _targetType;

    public CustomRoslynHost(
        Type targetType,
        IEnumerable<Assembly> additionalAssemblies = null,
        RoslynHostReferences references = null,
        ImmutableArray<string>? disabledDiagnostics = null) : base(additionalAssemblies, references, disabledDiagnostics)
    {
        _targetType = targetType;
    }

    protected override Project CreateProject(Solution solution, DocumentCreationArgs args, CompilationOptions compilationOptions, Project previousProject = null)
    {
        var projectId = ProjectId.CreateNewId();
        var projectInfo = ProjectInfo.Create(
            projectId,
            VersionStamp.Create(),
            "MyProject",
            "MyAssembly",
            LanguageNames.CSharp,
            compilationOptions: compilationOptions,
            parseOptions: new CSharpParseOptions(kind: SourceCodeKind.Script),
            metadataReferences: DefaultReferences,
            isSubmission: true,
            hostObjectType: _targetType);
        return solution.AddProject(projectInfo).GetProject(projectId);
    }
}

_roslynHostに設定していたインスタンスをCustomRoslynHostに置き換えましょう。
これで、インテリセンスを表示できるようになりました。例えば、_roslynHostのメンバーであるDefaultImportsを実行できます。

まとめ

この記事では、WPFC#エディターを作成する方法を紹介しました。RoslynとRoslynPadを使えば、短いコードで実装できますね!これで、皆さんのアプリケーションにもC#エディターを組み込むことができます。我々のようにAPIのテストに利用するなど、有用なケースがありましたら、ぜひ試してみてください。
最後まで読んでいただき、ありがとうございました!

執筆:@amabe.haruaki、レビュー:@sato.taichiShodoで執筆されました