自作の.NET製CLIツールにタブ補完機能を付ける(PowerShell編)

# .NET tool × オートコンプリート
C#使いとして日頃からお世話になっているdotnet CLI（dotnetコマンド）ですが、オートコンプリート（タブ補完）機能を付けられるのをご存じでしょうか。

## dotnet CLI のタブ補完を有効にする

https://learn.microsoft.com/ja-jp/dotnet/core/tools/enable-tab-autocomplete

PowerShell、Bash、zsh、fish、nushellなどの各種シェルに対し、専用のスクリプトを実行することで、パラメータの入力補完が使えるようになるというものです。

### PowerShellでの入力補完
タブ補完を有効化するスクリプトについて、PowerShellの実装を見てみるとRegister-ArgumentCompleter　というコマンドレットを使用するようです。

```powershell
# PowerShell parameter completion shim for the dotnet CLI
Register-ArgumentCompleter -Native -CommandName dotnet -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
        dotnet complete --position $cursorPosition "$commandAst" | ForEach-Object {
            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
        }
}
```

何やら既視感が...と思ったら、８年前に書いた自身の記事がヒットしました。PowerShellのコマンドに対して入力補完機能を付けるといった内容の記事ですね。

https://pierre3.hatenablog.com/entry/2015/12/08/222338

Register-ArgumentCompleterの使い方もほとんど変わりなさそうです。おそらく下記のような手順で動作するのだろうと予想できます。
1. dotnetコマンド入力中に[tab]キー、あるいは「Ctrl」+[Space]キーを押すとスクリプトブロックが実行される
2. スクリプトブロックに渡される情報を入力して dotnet CLIツールのcompleteコマンドを実行する
3. completeコマンド入力情報を元に適切な入力候補の一覧を返却する
4. 返却された一覧からCompletionResultオブジェクトを生成、それを元に入力候補がコンソール画面に表示される

自作CLIツールにタブ補完機能を付けるには、手順3. のcompleteコマンドをツール内に実装すれば良さそうです。

# 自作CLIツールにタブ補完機能を付ける
という事で、CLIツールにタブ補完機能を付けてみましょう！

## サンプルアプリの準備


:::note info
今回のサンプルアプリは下記リポジトリで公開しています。

https://github.com/pierre3/MyCLI/tree/sync
:::

### ConsoleAppFramework
サンプルとなるCLIツールの作成には、先日v5がリリースされた [Cysharp/ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework) を使用します。

コマンド定義用のクラス`MyCommands`を用意して、その中に必要なコマンドを実装するようにします。

```csharp:Program.cs
using ConsoleAppFramework;

var app = ConsoleApp.Create();
app.Add<MyCommands>();
await app.RunAsync(args);

partial class MyCommands
{
    public MyCommands()
    {
    }
}
```

MyCommandsにサンプル用のコマンドを４つほど定義します。コマンドはそれっぽいものをCopilotさんに作ってもらいました。

```csharp:Commands/Samples.cs
partial class MyCommands
{
    public void Search(string category, string sort, string filter)
    {
        Console.WriteLine($"Running... search --category {category} --sort {sort} --filter {filter}");
    }

    public void Share(string platform, string visibility, string tag)
    {
        Console.WriteLine($"Running... share --platform {platform} --visibility {visibility} --tag {tag}");
    }

    public void Edit(string file, string mode, bool backup)
    {
        Console.WriteLine($"Running... edit --file {file} --mode {mode} --backup {backup}");
    }

    public void View(string layout, string sort, string filter)
    {
        Console.WriteLine($"Running... view --layout {layout} --sort {sort} --filter {filter}");
    }
}
```

MyCommandクラスはpartialとし、コマンドの定義は、Commandsフォルダ配下に分割した.csファイルに記述する構成としています。
(新しい機能を追加したくなったら、Commandsフォルダ配下にcsファイルを増やしていくスタイル)

```plantuml
@startsalt
{
    {T
        + <size:20><&folder><b> MyCLI</b></size>
        ++ <size:20><&file> Program.cs</size>
        ++ <size:20><&folder><b> Commands</b></size>
        +++ <size:20><&file> Samples.cs</size>
        +++ <size:20><&file> MyWorks.cs</size>
        +++ <size:20><&file> hogehogeAPI.cs</size>
        +++ <size:20><&file> etc...</size>
    }
}
@endsalt
```

### アプリを.NET global toolとして動かす
コマンド名"mycli"でツールが実行できるように.NET tool としてパッケージ化しておきます。
MyCLI.csprojに下記内容を追加します。

```xml:MyCLI.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <!-- PropertyGroupセクションに追加-->
    <PackAsTool>true</PackAsTool>
    <ToolCommandName>mycli</ToolCommandName>
    <PackageOutputPath>./nupkg</PackageOutputPath>
    <Version>1.0.1</Version>
    <!--ここまで-->
  </PropertyGroup>
</Project>
```
下記コマンドでdotnet toolにパッケージングし、グローバルツールとしてインストールします。
```powershell
PS C:\work\MyCLI> dotnet pack
PS C:\work\MyCLI> dotnet tool install -g --add-source .\nupkg mycli
```

"mycli"を実行し、下記の様に表示されればOKです。
```powershell
PS C:\Work\MyCLI> mycli
Usage: [command] [-h|--help] [--version]

Commands:
  complete
  edit
  search
  share
  view
```

### PowerShellスクリプトの修正
冒頭で示した[dotnet CLI のタブ補完を有効にする](https://learn.microsoft.com/ja-jp/dotnet/core/tools/enable-tab-autocomplete) に記載されているPowerShellスクリプトを、自作アプリmycli 用に修正します。

- CommandNameの指定 ⇒ `-CommandName mycli `
- completeコマンドを実行するツール名 ⇒ `mycli complete `

```powershell
# PowerShell parameter completion shim for the mycli
Register-ArgumentCompleter -Native -CommandName mycli -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    mycli complete --word-to-complete $wordToComplete --input "$commandAst" --cursor-position $cursorPosition | ForEach-Object {
        [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
    }
}
```
このスクリプトが常に有効となるようPowerShellのプロファイルに追記しておきます。
下記コマンドでプロファイルを開き、スクリプトを貼り付けて保存します。

```powershell
PS > notepad $profile
```

## タブ補完機能の実装
サンプルアプリの準備が整いましたので、タブ補完機能の肝の部分の実装に取り掛かりましょう。

### complete コマンドの定義

MyCommandsクラスに Completeメソッドを追加します。
メソッド名、引数名などはConsoleAppFrameworkの命名規則に従い、下記の通りに定義します。
結果の出力は、メソッドの戻り値ではなくコンソールへの出力とします。

```csharp:Program.cs
partial class MyCommands
{
    public void Complete(string wordToComplete, string input, int cursorPosition)
    {
        //入力状況に応じて適切な入力候補を出力する
        //戻り値ではなくコンソール出力を使う
        Console.WriteLine("xxxx");
    }
}
```
:::note info
コマンドラインでの引数名はケバブケースですが、Completeメソッドではキャメルケースで受け取ります。
```PowerShell
> mycli complete --word-to-complete "x" --input "mycli complete x" --cursor-position 16
:::


### 動作イメージ

completeコマンドに渡されるパラメータは次の３つです。

- `--word-to-complete`: 入力中の語句
- `--input`: 入力したコマンドライン全体
- `--cursor-position`: カーソルの位置

completeコマンドは、これらの情報を元に適切な入力候補の一覧を返却する必要があります。

- 例1) コマンドの一覧を返却するケース
```powershell
> mycli ❚  #[tab] or [ctrl]+[space]

> mycli complete `
    --word-to-complete "" `
    --input "mycli" `
    --cursor-position 6
=> ["search", "share", "edit", "view"] #コマンド名の一覧を返却
```

- 例2) 利用可能なオプションの一覧を返却するケース
```powershell
> mycli search --❚  #[tab] or [ctrl]+[space]

> mycli complete `
    --word-to-complete "--" `
    --input "mycli search --" `
    --cursor-position 15
=> ["--category", "--sort", "--filter"] #searchコマンドの引数一覧を返却

```

- 例3) 指定したオプションに対する入力候補を返却するケース 
```powershell
> mycli search --category bo❚  #[tab] or [ctrl]+[space]

> mycli complete `
    --word-to-complete "bo" `
    --input "mycli search --category bo" `
    --cursor-position 26
=> ["--book"]  #searchコマンドの--categoryオプションの選択候補から"bo"を含むものを返却

```

### 入力候補を管理するインターフェイス・クラスの定義

#### ICommandCompletionItem インターフェイス

まず、コマンド毎のオプション一覧と入力可能な値の一覧を取得するためのインターフェイスICommandCompletionItemを定義します。

```csharp:ICommandCompletionItem.cs
interface ICommandCompletionItem
{
    //コマンド名
    string CommandName { get; }
    //コマンドで利用可能な全てのオプションを取得
    IEnumerable<string> GetAllOptions();
    //指定した文字列を含むオプションの一覧を取得
    IEnumerable<string> GetOptions(string wordToComplete);
    //指定したオプションに対して設定可能な値の一覧を取得
    IEnumerable<string> GetCompletionItems(string optionName, string wordToComplete);
}
```

#### ICommandCompletionProvider インターフェイス

ICommandCompletionItem を受け取り、オートコンプリートの入力候補を取得する処理は、このインターフェイスに切り出します。

```csharp:ICommandCompletionProvider.cs
interface ICommandCompletionProvider
{
    //受け取る
    void Add(ICommandCompletionItem item);
    IEnumerable<string> Complete(string wordToComplete, string input, int cursorPosition);
}
```

#### CommandCompletionItem クラス
ICommandCompletionItem を実装したクラスです。オプション名をKey、オプションで利用可能な設定値の一覧をValueとしたDictionaryにデータを保持します。

```csharp:CommandCompletionItem.cs
using System.Collections;

class CommandCompletionItem(string commandName) : ICommandCompletionItem, IEnumerable
{
    private readonly Dictionary<string, IEnumerable<string>> items = new();

    public string CommandName { get; } = commandName;

    public IEnumerable<string> GetAllOptions() => items.Keys;

    public IEnumerable<string> GetOptions(string wordToComplete)
        => items.Keys.Where(o => o.Contains(wordToComplete, StringComparison.InvariantCultureIgnoreCase));

    public IEnumerable<string> GetCompletionItems(string optionName, string wordToComplete)
        => items[optionName].Where(o => o.Contains(wordToComplete, StringComparison.InvariantCultureIgnoreCase));

    
    public void Add(string key, IEnumerable<string> value) => items.Add(key, value);

    IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator();
}
```

#### CommandCompletionProvider クラス

ICommandCompletionProvider を実装したクラスです。入力された文字列を解析してICommandCompletionItem から適切な入力候補を返します。

処理の詳細はコード内のコメントをご確認ください。
（ざっくり動作確認はしていますが、テストは不十分ですので、ご参考程度に）
なお、ネストしたコマンド(dotnet add packageのような)には対応していません。


<details><summary>ソースコードはこちら</summary>

```csharp:CommandCompletionProvider
using System.Collections;

//コマンドの補完を提供するクラスです。
class CommandCompletionProvider : ICommandCompletionProvider, IEnumerable
{
    // コマンド補完アイテムのリストを保持します。
    private readonly IList<ICommandCompletionItem> _items = [];
    // コマンド補完アイテムをリストに追加します
    public void Add(ICommandCompletionItem item) => _items.Add(item);
    
    IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator();

    // 指定された単語を補完するための候補を取得します。
    public IEnumerable<string> Complete(string wordToComplete, string input, int cursorPosition)
    {
        var tokens = Parse(input, cursorPosition).Skip(1).ToArray();
        return GetCompletionItems(tokens, wordToComplete);
    }

    // 補完候補を取得します
    private IEnumerable<string> GetCompletionItems(string[] tokens, string wordToComplete)
    {
        //コマンド入力なし
        if (tokens.Length == 0)
        {
            return GetCommandNames("");
        }
        //最初の要素がコマンド名
        var cmd = _items.FirstOrDefault(x => tokens[0] == x.CommandName);
        if (cmd == null)
        {
            //コマンド未入力 or 入力途中の場合、コマンド名一覧を表示
            return GetCommandNames(wordToComplete);
        }
        //入力中のコマンドからオプション一覧を取得
        var allOptions = cmd.GetAllOptions().ToArray();
        if (tokens.Length == 1) //コマンドのみ指定済み
        {
            return cmd.GetOptions(wordToComplete);
        }

        var op1 = allOptions.FirstOrDefault(o => o == tokens[^1]);
        //最後の入力値がオプションと一致
        if (op1 != null)
        {
            //オプションに対する入力候補を取得
            return GetCompletionItemByOptionName(cmd, op1, tokens, allOptions, wordToComplete);
        }

        var op2 = allOptions.FirstOrDefault(o => o == tokens[^2]);
        //後ろから２つ目がオプションの場合
        if (op2 != null && wordToComplete != "")
        {
            //オプションに対する入力候補を取得
            return GetCompletionItemByOptionName(cmd, op2, tokens, allOptions, wordToComplete);
        }

        return cmd.GetOptions(wordToComplete).Where(o => !tokens.Contains(o));
    }

    // オプション名に基づいて補完候補を取得します
    private static IEnumerable<string> GetCompletionItemByOptionName(
        ICommandCompletionItem cmd, string optionName, string[] tokens, string[] options, string wordToComplete)
    {
        //オプションに対する入力候補を取得
        var item = cmd.GetCompletionItems(optionName, wordToComplete);
        if (!item.Any())
        {
            //値の候補なしの場合オプションを候補として出す
            return cmd.GetOptions(wordToComplete).Where(o => !tokens.Contains(o));
        }
        //入力中の文字列でフィルタ
        return item;
    }

    // コマンド名の一覧を取得します
    private IEnumerable<string> GetCommandNames(string wordToComplete)
    {
        if (wordToComplete == "")
        {
            return _items.Select(x => x.CommandName);
        }
        return _items.Where(x => x.CommandName.Contains(wordToComplete)).Select(x => x.CommandName);
    }
    
    // 入力文字列を解析してトークンに分割します
    private static IEnumerable<string> Parse(string input, int position)
    {
        var source = input[0..Math.Min(input.Length, position)];
        var token = "";
        var inQuote = false;
        var isEscaped = false;
        for (int i = 0; i < source.Length; i++)
        {
            var c = source[i];
            if (isEscaped)
            {
                token += c;
                isEscaped = false;
                continue;
            }
            switch (c)
            {
                case '"':
                    inQuote = !inQuote;
                    break;
                case '\\':
                    isEscaped = true;
                    break;
                case ' ':
                    if (inQuote)
                    {
                        token += c;
                    }
                    else if (!string.IsNullOrEmpty(token))
                    {
                        yield return token;
                        token = "";
                    }
                    break;
                default:
                    token += c;
                    break;
            }
        }
        if (!string.IsNullOrEmpty(token))
        {
            yield return token;
        }
    }

}
```
</details>

### Completeメソッド
これで部品が揃いました。完成したCompleteメソッドを見てみましょう。

`MyCommands`クラスのコンストラクタで`CommandCompletionProvider`を初期化しています。
Completeメソッド内ではCompletionProviderのComplete()を実行して取得した結果をコンソールに出力しています。

```csharp:Program.cs
partial class MyCommands
{
    private readonly CommandCompletionProvider CompletionProvider;

    public MyCommands()
    {
        CompletionProvider =
            [
                new CommandCompletionItem("search")
                {
                    {"--category", ["books","movies","music"]},
                    {"--sort", ["relevance","date","popularity"]},
                    {"--filter", ["free","paid","all"]}
                },
                new CommandCompletionItem("share")
                {
                    {"--platform", ["facebook","twitter","linkedin"]},
                    {"--visibility", ["public","private","friends"]},
                    {"--tag", ["fun","education","promotion"]}
                },
                new CommandCompletionItem("edit")
                {
                    {"--file", ["document1","document2","document3"]},
                    {"--mode", ["read","write","append"]},
                    {"--backup", []}
                },
                new CommandCompletionItem("view")
                {
                    {"--layout", ["grid","list","detail"]},
                    {"--sort", ["name","date","size"]},
                    {"--filter", ["all","folders","files"]}
                },
            ];
    }

    public void Complete(string wordToComplete, string input, int cursorPosition)
    {
        var items = CompletionProvider
            .Complete(wordToComplete, input, cursorPosition);
        foreach (var item in items)
        {
            Console.WriteLine(item);
        }
    }
}
```

## 実行例
コードが完成したら、もう一度パッケージングと再インストールを実行します。

```powershell
PS C:\work\MyCLI> dotnet pack
PS C:\work\MyCLI> dotnet tool install -g --add-source .\nupkg mycli
```

では、試してみましょう！

想定通り「Tab」キーを押すたびに、入力候補が切り替わりました！

![completion2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/279a96c5-d897-f422-fa03-ca2775f890f3.gif)

「Ctrl」+ 「Space」キーを使うと、候補の一覧から選択も可能です。

![completion.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/6f9d4f2f-2a68-34b9-f8a4-66d314eaddd7.gif)


# まとめ

受け取ったコマンドの情報から、適切な入力情報を出力する部分は少し大変でしたが、タブ補完を追加する仕組み自体は割とシンプルでした。

## 次回
今回の実装では入力候補となる値は、プログラム内に直接記載した固定値でした。
しかし、APIなどで動的に取得した値を入力候補として使いたいこともあると思います。

そこで、次回「タブ補完の入力候補を動的に取得する」に続きます

https://qiita.com/pierusan2010/items/e820ab9faf57aba0f2bd