これは電通国際情報サービス Advent Calendar 2021の10日目の記事です。
はじめに
はじめまして!電通国際情報サービス(ISID) 製造ソリューション事業部の余部です。
構想設計支援システムiQUAVIS(アイクアビス)の開発を担当しています。
iQUAVISは自社でスクラッチ開発しているWPFアプリケーションです。機能を独自に拡張できるプラグインを作成するためのSDKとして、多くのAPIがあります。これを簡単にテストできるよう、アプリケーション上でC#を記述してAPIを実行できるエディターをテスト用のプラグインとして開発しました。これによってテスト効率が大幅に上がり、SDKの開発で欠かせない機能になっています。
今回は、WPFでC#を実行するエディターを作成する方法を紹介します。
利用技術
- Roslyn
C#とVisual Basicのコンパイラをオープンソースで実装した、.NETのコンパイラプラットフォームです。コードを静的解析するためのAPIも備えています。 - RoslynPad
SharpDevelopで使われている AvalonEdit をベースにしたC#のエディターです。文字通りRoslynを使って実装されています。 独自のアプリにエディターを組み込めるよう、NuGetパッケージとしても公開されています。
開発環境
- Visual Studio 2019
- .NET 5.0
- C# 9.0
エディターの作成
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 struct
はC#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.Initialize
でCompositionFailedException
が発生します。次の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
を実行できます。
まとめ
この記事では、WPFでC#エディターを作成する方法を紹介しました。RoslynとRoslynPadを使えば、短いコードで実装できますね!これで、皆さんのアプリケーションにもC#エディターを組み込むことができます。我々のようにAPIのテストに利用するなど、有用なケースがありましたら、ぜひ試してみてください。
最後まで読んでいただき、ありがとうございました!
執筆:@amabe.haruaki、レビュー:@sato.taichi (Shodoで執筆されました)