.NETのCUIアプリケーション用フレームワーク『CuiLib』をメジャーアップデートしました

以前制作した[CUIアプリケーション開発用フレームワーク『CuiLib』](https://qiita.com/Funny_Silkie/items/432763d0725bfa6dc743)を大規模改修し，リリースしました。
クラス設計を見直した部分が多々あり破壊的変更を多く伴うため，メジャーアップデートとしています。
当記事は，執筆時点での最新バージョン「2.0.1」について触れていきます。

## リンク

- GitHub: https://github.com/Funny-Silkie/CuiLib
- NuGet: https://www.nuget.org/packages/CuiLib
- リリースログ: https://github.com/Funny-Silkie/CuiLib/blob/master/docs/RelHistory.md

## 目玉
### 対応する.NETバージョンを拡大

これまでは.NET 7.0のみでしたが，以下のランタイムにも対応するようになりました。
.NET Standardに対応したため，.NET Framework 4.6.2-4.8.1・.NET Core 2.0-3.1のプロジェクトから当フレームワークを利用できるようになっております。
- .NET Standard 2.0
- .NET Standard 2.1
- .NET 6.0
- .NET 8.0
- .NET 9.0

複数ランタイム対応について工夫した点など，今後記事にしていけたらいいなと思っています。

### テストを拡充し，不具合炙り出し＆修正

以前はテストの書き方について詳しくなかったため，最低限の動作しか保証されていない状態でした。
今回メジャーアップデートするにあたって，単体テストを網羅的に実装しました。
これによって多数の不具合が検出されたため，まとめて修正しました。
具体的な修正内容については冒頭のリリースノートのリンクからご確認ください。

### ヘルプメッセージの細かいカスタマイズが可能に

これまでは `Command.WriteHelp(TextWriter)` メソッドで対象のコマンドのヘルプメッセージを出力していました。
ただ，ヘルプメッセージのカスタマイズを行う際にはこのメソッドをオーバーライドして，一からヘルプメッセージを組み立て直す必要がありました。
当バージョンからは `CuiLib.Output.IHelpMessageProvider` インターフェイスと `CuiLib.Output.HelpMessageProvider` クラスが新たに実装されて， `WriteHelp` メソッドのオーバーロードに `WriteHelp(TextWriter, IHelpMessageProvider)` が追加されています。
`HelpMessageProvider` クラスは `IHelpMessageProvider` インターフェイスのデフォルトの実装で，継承することでヘッダー部分やオプション部分といった部分的なカスタマイズを行うことができます。

<details><summary>サンプルコード</summary>

```cs:二種類のヘルプメッセージを出力するC#サンプル
using System;
using System.IO;
using System.Linq;
using CuiLib.Checkers;
using CuiLib.Commands;
using CuiLib.Options;
using CuiLib.Output;
using CuiLib.Parameters;

internal class Program
{
    private static void Main(string[] args)
    {
        var command = new SampleCommand();

        // デフォルトのヘルプメッセージ
        command.WriteHelp(Console.Out, messageProvider: null);

        // カスタマイズしたヘルプメッセージ
        command.WriteHelp(Console.Out, new SampleHelpMessageProvider());
    }
}

internal class SampleHelpMessageProvider : HelpMessageProvider
{
    // オプション部分だけカスタマイズ
    public override void WriteOptions(TextWriter writer, Command command)
    {
        writer.WriteLine("Options:");
        foreach (NamedOption option in command.Options.OfType<NamedOption>())
        {
            writer.Write("  ");
            if (option.ShortName is not null)
            {
                writer.Write($"-{option.ShortName}");
                if (option.ValueTypeName is not null) writer.Write($" {option.ValueTypeName.ToUpper()}");
                writer.Write(", ");
            }

            writer.Write($"--{option.FullName}");
            if (option.ValueTypeName is not null) writer.Write($" {option.ValueTypeName.ToUpper()}");
            writer.WriteLine(':');

            writer.WriteLine($"  {option.Description}");
            writer.WriteLine();
        }
    }
}

internal class SampleCommand : Command
{
    private readonly FlagOption optionHelp;
    private readonly FlagOption optionVersion;
    private readonly MultipleValueOption<FileInfo> optionInput;
    private readonly SingleValueOption<int> optionNum;
    private readonly MultipleValueParameter<string> parameters;

    public SampleCommand() : base("sample")
    {
        Description = "Sample command";

        optionHelp = new FlagOption('h', "help")
        {
            Description = "Display help",
        };
        optionVersion = new FlagOption('v', "version")
        {
            Description = "Display version",
        };
        optionInput = new MultipleValueOption<FileInfo>('i', "in")
        {
            Description = "Input files",
            Checker = ValueChecker.ValidSourceFile(),
            Required = true,
        };
        optionNum = new SingleValueOption<int>("number")
        {
            Description = "Some number",
            Required = false,
        };
        parameters = new MultipleValueParameter<string>("params", 0)
        {
            Description = "Other parameters",
        };

        Options.Add(optionHelp);
        Options.Add(optionVersion);
        Options.Add(optionInput);
        Options.Add(optionNum);

        Parameters.Add(parameters);
    }
}
```

```:デフォルトの出力
sample

Description:
Sample command

Usage:
sample [-h] [-v] -i file [--number int] <params ..>

Options:
  -h, --help     Display help
  -v, --version  Display version
  -i, --in       Input files
      --number   Some number

Parameters:
  params  Other parameters
```

```:カスタマイズした出力
sample

Description:
Sample command

Usage:
sample [-h] [-v] -i file [--number int] <params ..>

Options:
  -h, --help:
  Display help

  -v, --version:
  Display version

  -i FILE, --in FILE:
  Input files

  --number INT:
  Some number


Parameters:
  params  Other parameters
```

</details>

### 数値の範囲を表す型 `ValueRange` ・ `ValueRangeCollection` を実装

コマンドラインアプリケーションを開発する際に， `1-3,5` のような数値の範囲を解析したくなる場合が出てきます。
そこで，範囲を表す型として `CuiLib.Data.ValueRange` 構造体と `CuiLib.Data.ValueRangeCollection` クラスを実装しました。
`ValueRange` 構造体は範囲 `1-3,5` のうち， `1-3` や `5` といった「連続した範囲」または「単一の値」を表します。
`ValueRangeCollection` クラスは `ValueRange` 構造体のコレクションを表します。
両型とも `IParsable<T>` インターフェイスを実装しており， `Parse` メソッドで文字列からの変換をサポートしています。
`IValueConverter<TIn, IOut>` による変換もサポートされており， `SingleValueOption<T>` や `SingleValueParameter<T>` といった引数用のクラスの型引数に `ValueRangeCollection` を指定するだけで自動的に変換してくれます。
尚，どちらの型も `IEnumerable<T>` を実装しているため，LINQによる処理を行うことも可能です。

<details><summary>サンプルプログラム</summary>

```cs
using System;
using System.IO;
using CuiLib.Commands;
using CuiLib.Data;
using CuiLib.Options;

internal class Program
{
    private static void Main(string[] args)
    {
        using var writer = new StreamWriter("test.txt", false);
        var command = new SampleCommand();

        command.Invoke(["-r", "1-3,5,10-30,100"]);
    }
}

internal class SampleCommand : Command
{
    private readonly SingleValueOption<ValueRangeCollection> optionRange;

    public SampleCommand() : base("sample")
    {
        Description = "Sample command";

        optionRange = new SingleValueOption<ValueRangeCollection>('r', "range")
        {
            Description = "range of value",
            Required = true,
        };

        Options.Add(optionRange);
    }

    protected override void OnExecution()
    {
        ValueRangeCollection ranges = optionRange.Value;

        Console.WriteLine(ranges);
        foreach (ValueRange currentRange in ranges)
        {
            if (currentRange.Start == currentRange.End) Console.WriteLine($"  {currentRange}: represents single value '{currentRange}'");
            else Console.WriteLine($"  {currentRange}: represents the range {currentRange.Start} to {currentRange.End}");
        }
    }
}
```

```:出力
1-3,5,10-30,100
  1-3: represents the range 1 to 3
  5: represents single value '5'
  10-30: represents the range 10 to 30
  100: represents single value '100'
```

</details>

## 新バージョン移行の際の注意点

今回のアップデートに際して破壊的変更が多数行われているため，バージョン移行の手引きをこちらに示しておきます。

### 命名の修正
命名の大整理を行ったため，バージョンを変えた直後はコンパイルエラーが多発します。
以下の項を基に参照や名前を更新してください。

#### 名前空間の変更

- `CuiLib.Log` 名前空間以下の全型 → `CuiLib.Logging`
- `IValueChecker<T>` インターフェイスと `ValueChecker` クラスについて： `CuiLib.Options` → `CuiLib.Checkers`
- `IValueConverter<TIn, TOut>` インターフェイスと `ValueConverter` クラスについて： `CuiLib.Options` → `CuiLib.Converters`
- `Parameter` クラスとその派生型， `ParameterCollection` クラスについて： `CuiLib.Options` → `CuiLib.Parameters`

#### クラス名の変更

- `IOHelper` → `IOHelpers`

#### `ValueChecker` クラスのメソッド

- `AlwaysSuccess` -> `AlwaysValid`
- `StartWith` -> `StartsWith`
- `EndWith` -> `EndsWith`
- `Contains` -> `ContainedIn`
- `Larger` -> `GreaterThan`
- `LargerOrEqual` -> `GreaterThanOrEqualTo`
- `Lower` -> `LessThan`
- `LowerOrEqual` -> `LessThanOrEqualTo`
- `Equals` -> `EqualTo`
- `NotEquals` -> `NotEqualTo`
- `IsRegexMatch` -> `Matches`
- `FileExists` -> `ExistsAsFile`
- `DirectoryExists` -> `ExistsAsDirectory`
- `VerifySourceFile` -> `ValidSourceFile`
- `VerifyDestinationFile` -> `ValidDestinationFile`
- `VerifySourceDirectory` -> `ValidSourceDirectory`

### `Parameter<T>` クラスの設計変更

オプションと取り回しを合わせるため， `Parameter<T>` クラスの設計を変更しました。
値を一つだけ取るオプションは `SingleValueParameter<T>` クラスに，複数の値をとるオプションは `MultipleValueParameter<T>` クラスに分割されています。
そして， `Checker` プロパティと `Converter` プロパティが `Parameter<T>` クラスから削除されて各派生クラスへ移行されています。
そのため `Parameter<T>.Checker` と `Parameter<T>.Converter` を参照している場合は `SingleValueParameter<T>` か `MultipleValueParameter<T>` へキャストします。
後者については加えて以下の修正を行います。
- `Values` プロパティ → `Value` プロパティ
- `Parameter<T>` として代入している場合 → `Parameter<T[]>` へ修正

## 最後に

改修を始めたから半年以上経過しましたが，やっとリリースすることができました。
GitHubのIssueがまだ残っているので，合間を縫ってぼちぼち実装をしていくつもりです。
不具合の報告や機能追加の要望などありましたら，[Issue](https://github.com/Funny-Silkie/CuiLib/issues)へ遠慮なくお知らせください。