C#スクリプト エディタに入力補完機能を実装する

この記事は、以下の記事の続きとなります。

この記事のサンプルコードはこちら github.com

これまでは、読み込んだ設定スクリプトをTextBoxコントロールに表示していたのですが、
今回、もう少し"コードエディタっぽい" エディタに変更して、ソースコードの表示・編集が出来るようにしたいと思います。

目次

C#スクリプトエディタにAvalonEditを使う

AvalonEditは、WPF用の高機能テキストエディタライブラリで、ソースコードシンタックスハイライトや入力補完機能を備えています。
今回は設定スクリプトの表示、編集用にこのライブラリを使用したいと思います。

AvalonEdit by icsharpcode

NuGet からインストール可能です。

PM> Install-Package AvalonEdit

XAML での使用例は以下の通りです。
AvalonEdit:TextEditor というコントロールを配置します。シンタックスハイライトや、行番号の表示有無などが指定可能です。

<Window x:Class="CsvEditor.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:CsvEditor"
        xmlns:avalonEdit="http://icsharpcode.net/sharpdevelop/avalonedit"
        mc:Ignorable="d"
        Title="MainWindow" Height="580" Width="960">
    <Grid>
        <!-- 中略 -->
        <avalonEdit:TextEditor 
            x:Name="configEdit"
            Document="{Binding ConfigurationDoc,Mode=TwoWay}"
            SyntaxHighlighting="C#"
            ShowLineNumbers="True"
            FontFamily="Consolas" FontSize="10Pt">
        </avalonEdit:TextEditor>
        <!-- 中略 -->
    </Grid>
</Window>

C#スクリプトエディタに入力補完機能を付ける

AvalonEdit の入力補完ウィンドウ

AvalonEdit には、入力補完ウィンドウを表示する仕組みが備わっています。

公式ドキュメント(http://avalonedit.net/documentation/ の"Code Completion")のサンプルを参考に以下のような記述を行うだけで、補完ウィンドウの表示自体は簡単に実装することが可能です。

//MainWindowのコードビハインド
public partial class MainWindow : Window
{
    private CompletionWindow completionWindow;

    public MainWindow()
    {
        InitializeComponent();
        //イベントハンドラを登録
        configEdit.TextArea.TextEntered += TextArea_TextEntered;
        configEdit.TextArea.TextEntering += TextArea_TextEntering;
    }
    //文字入力中
    private void TextArea_TextEntering(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        //補完Windowが開いている間
        if (e.Text.Length > 0 && completionWindow != null)
        {
            //英数字以外が入力された場合
            if (!char.IsLetterOrDigit(e.Text[0]))
            {
                //選択中のリストの項目をエディタに挿入する                
                completionWindow.CompletionList.RequestInsertion(e);
            }
        }
        // HandledはTrueにしない
        // e.Handled=true;
    }
    //文字入力後
    private async void TextArea_TextEntered(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        // ピリオドを入力
        if (e.Text == ".")
        {
            //入力補完Windowを生成
            completionWindow = new CompletionWindow(configEdit.TextArea);
            //補完リストに表示するアイテムをコレクションに追加する
            //---> ここは、編集内容に応じて適切な入力候補を追加するように書き換える!
            IList<ICompletionData> data = completionWindow.CompletionList.CompletionData;
            data.Add(new CompletionData("item1"));
            data.Add(new CompletionData("item2"));
            data.Add(new CompletionData("item3"));
            //<---
            //Windowを表示
            completionWindow.Show();
            completionWindow.Closed += delegate
            {
                completionWindow = null;
            };
        }
    }
}
  • 入力候補一覧には、ICompletionData を実装したクラスのインスタンスを追加してあげる必要があります。
public class CompletionData : ICompletionData
{
    //入力候補一覧(UI)に表示する内容
    public object Content { get; set; }
    //ツールチップに表示する説明文
    public object Description { get; set; }
    //入力候補の左側に表示するアイコン
    public ImageSource Image { get; set; }
    //表示順の優先度?
    public double Priority { get; set; }
    //アイテム選択時に挿入される文字列
    public string Text { get; set; }
    //アイテム選択後の処理
    public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs)
    {
        textArea.Document.Replace(completionSegment, Text);
    }
}

Roslyn Workspaces API の Recommender を利用して入力候補一覧を取得する

AvalonEdit を利用することで、入力補完Window 自体を表示することができるのは分かったのですが、肝心の入力候補の一覧はどうやって作ればよいのでしょうか?

Roslyn の Workspaces API 辺りにコード補完の機能があったような。。。
と調べてみると、どうやら Microsoft.CodeAnalysis.Recommendations.Recommender クラスのGetRecommendedSymbolsAtPositionAsync というメソッドが使えそうだという事が分かりました。

Roslyn Workspases API もNuGetで追加します。

PM> Install-Package Microsoft.CodeAnalysis.Workspaces

Recommender.GetRecommendedSymbolsAtPositionAsync()

(このメソッドに関する情報が非常に少ないため、メソッド名と戻り値等から予測すると)
このメソッドは、ソースコード上の指定した場所(position)におけるコード補完候補の一覧を IEnumerable<ISymbol> で返してくれるメソッドのようです。

シグネチャは以下の通り。引数にはposition のほかに、SemanticModel と Workspace を渡す必要があるようです。

public static Task<IEnumerable<ISymbol>> GetRecommendedSymbolsAtPositionAsync(
    SemanticModel semanticModel,
    int position,
    Workspace workspace,
    OptionSet options = null,
    CancellationToken cancellationToken = default(CancellationToken));
(引数1) semanticModel

SemanticModelは、C#スクリプトを実行する際に作成する Script<T>オブジェクトから取得できるCompilation オブジェクトから取得可能なので、これを指定すれば良さそうです。

private void CompileScript(string code)
{
    Script<object> script = CSharpScript.Create(code, ScriptOptions.Default
        .WithReferences(
            typeof(object).Assembly,
            typeof(CsvReader).Assembly,
            typeof(ICsvEditorConfigurationHost).Assembly)
        .WithImports(
            "System",
            "System.Collections.Generic",
            "System.Linq"),
        typeof(ICsvEditorConfigurationHost));
    //SemanticModel の取得
    var compilation = script.GetCompilation();
    var syntaxTree = compilation.SyntaxTrees.First();
    var semanticModel = compilation.GetSemanticModel(syntaxTree);
    //Workspaceの作成
    //...(略)
}
(引数2) positon

ソースコード上の位置(エディタで補完機能を呼び出した際のキャレット位置)を指定すれば良さそう。

(引数3) workspace

ソースコードはWorkspace の形で渡してあげる必要があります。

Workspace を一から作成するには AdhocWorkspace クラスを使用します。

//Workspaceの作成
Workspace workspace = new AdhocWorkspace();
//空のprojectを作成
string projectName = "CsvEditorConfig";
ProjectId projectId = ProjectId.CreateNewId();
VersionStamp versionStamp = VersionStamp.Create();
ProjectInfo projectInfo 
    = ProjectInfo.Create(projectId, versionStamp, projectName, projectName, LanguageNames.CSharp);
Project project = workspace.AddProject(projectInfo);
//Documentを追加
SourceText sourcetext = Microsoft.CodeAnalysis.Text.SourceText.From(code);
workspace.AddDocument(project.Id, "Config.csx", sourcetext);

ICompletionData のコレクションを作成する

AvalonEdit の CompletionWindow に渡す入力候補ICompletionData インターフェースを実装したオブジェクトである必要がありました。

次は、GetRecommendedSymbolsAtPositionAsync() メソッドから取得したISymbolのコレクション を ICompletionData のコレクションに変換して返すメソッドを作ります。

public class MainWindowViewModel : BindableBase
{
    private Script<object> script;
    private SemanticModel semanticModel;
    private AdhocWorkspace workspace = new AdhocWorkspace();
    
    public async Task<IEnumerable<CompletionData>> GetCompletionListAsync(int position,string code)
    {
        CompileScript(code);
        if (semanticModel == null || workspace == null)
        {
            return Enumerable.Empty<CompletionData>();
        }
        var items = await Recommender.GetRecommendedSymbolsAtPositionAsync(
            semanticModel, position, workspace);
        return items.Select(symbol => new CompletionData()
        {
            //ListBoxに表示する内容
            Content = symbol.Name,
            //エディタに挿入する文字列
            Text = symbol.Name,
            //ツールチップに表示する文字列
            Description = symbol.ToMinimalDisplayString(semanticModel, position)
        });
    }
}

これをViewModelに実装して、View側 (コードビハインド) に記述した TextArea_TextEntered イベントハンドラで呼び出すようにします。
また、ピリオド(.)のほかに[Ctrl]+[Space]キーでも入力補完ウィンドウを表示するようにOnKeyDown() メソッドをオーバーライドします。

public partial class MainWindow : Window
{
    private CompletionWindow completionWindow;
    private MainWindowViewModel VM { get { return DataContext as MainWindowViewModel; } }

    private async void TextArea_TextEntered(object sender, 
        System.Windows.Input.TextCompositionEventArgs e)
    {
        if (VM == null) { return; }
        // Open code completion after the user has pressed dot:
        if (e.Text == ".")
        {
            await ShowCompletionWindow();
        }
    }

    protected override async void OnKeyDown(KeyEventArgs e)
    {
        base.OnKeyDown(e);
        //Ctrl+Space でも表示するように
        if (e.Key == Key.Space && e.KeyboardDevice.Modifiers.HasFlag(ModifierKeys.Control))
        {
            e.Handled = true;
            await ShowCompletionWindow();
        }
    }    

    private async Task ShowCompletionWindow()
    {
        if (VM == null) { return; }
        completionWindow = new CompletionWindow(configEdit.TextArea);
        IList<ICompletionData> data = completionWindow.CompletionList.CompletionData;

        var completionItems = await VM.GetCompletionListAsync(
            configEdit.TextArea.Caret.Offset + 1,
            configEdit.Document.Text + " ");

        foreach (var item in completionItems)
        {
            data.Add(item);
        }
        completionWindow.Show();
        completionWindow.Closed += delegate
        {
            completionWindow = null;
        };
    }
}

実行してみよう

長くなってしまいましたが、これで一通りの実装が完了しました。
それでは、実行結果を見てみましょう。

f:id:pierre3:20160727223655g:plain

  • ソースコードの編集箇所に応じた入力候補がちゃんと表示されています。
  • デリゲートの中の引数でも補完が利くようです。

中々いい感じに動いていますね。

※ あとは、以下の2点を何とかすれば完璧なのですが、今回はあきらめました。

まとめ

  • AvalonEdit は 高機能なテキストエディタを簡単に実装できていいぞ!
  • コンテキストに応じた入力候補の取得には Microsoft.CodeAnalysis.Recommendations.Recommender が使えるよ!