C# + WindowsにおけるANSIエスケープシーケンス書式設定

Windows[^1] のコンソールアプリケーションにおいて、ANSIエスケープシーケンスによる書式設定を用いて、簡単な文字装飾を行うことを考えます。

[^1]: Windows 10 Anniversary Update 以降

![cmd.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629525/dd1b7ad7-4d11-4d7a-ea0d-77a79259301a.png)

まず最初に、以下のとおりです。

:::note warn
これには専門のライブラリ（[Cysharp/Kokuban](https://github.com/Cysharp/Kokuban)など）があったり、コンソールアプリケーション用ライブラリの機能として提供されていたりします。
機能の実現が目的でそれらを用いることが可能な場合、そうする方が良いでしょう。
:::

出発点として、「多彩な表現力を必要としない場合はライブラリ抜きで簡潔にやれないか」という考えがありました。

結果としては、簡単な装飾でも、可能ならライブラリを使うほうが良い、という結論に至ったのですが、調べたり試した内容を記事としました。

## 仮想ターミナルシーケンスを有効にする

Windowsにおいて、標準ではANSIエスケープシーケンスによる書式設定が機能しません。有効にする必要があります。

https://learn.microsoft.com/ja-jp/windows/console/console-virtual-terminal-sequences

:::note
開発環境のコンソール（エディタでのデバッグ実行など）では、この手順を経なくてもおそらく機能します。
:::

以下は有効にする例です。（`LibraryImort`属性は .NET 7～）

```cs
using System.Runtime.InteropServices;

public static class WindowsConsole
{
    public static bool TryEnableVirtualTerminalProcessing()
    {
        const int STD_OUTPUT_HANDLE = -11;
        const int INVALID_HANDLE_VALUE = -1;
        var handle = NativeMethods.GetStdHandle(STD_OUTPUT_HANDLE);
        if (handle is INVALID_HANDLE_VALUE) return false;

        if (NativeMethods.GetConsoleMode(handle, out var mode) is false) return false;

        const int ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
        return NativeMethods.SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
    }
}

internal static partial class NativeMethods
{
    [LibraryImport("kernel32.dll")]
    internal static partial nint GetStdHandle(int nStdHandle);

    [LibraryImport("kernel32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool GetConsoleMode(nint handle, out int mode);

    [LibraryImport("kernel32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool SetConsoleMode(nint handle, int mode);
}
```

```cs
// 最初に呼ぶ
WindowsConsole.TryEnableVirtualTerminalProcessing();
```

厳密にやるのであればプラットフォームでの分岐などが考えられますが、今回はこれで済ませます。

## エスケープシーケンスによる書式設定

書式は`\u001[<n>m`として指定します。このときC# 13以降であれば、エスケープ文字（`\u001`）に代えて`\e`を使うことができます。（記事内では後者を使用）

`<n>`にあたる指定は、前掲 Microsoft Lern 記事内の[テキストの書式設定](https://learn.microsoft.com/ja-jp/windows/console/console-virtual-terminal-sequences#text-formatting)の項で確認できます。例えば、文字色を明るい黄にしたければ`\e[93m`とします。

そして、指定したい範囲を終えたら指定内容をリセットする指定を行います。例えば文字色をリセットするなら`\e[39m`になります。`\e[0m`でも既定の状態に戻せますが、これは全属性をリセットします。

これらを合わせて例えば、「warn」という文字を明るい黄色にし、それ以降を標準で表示するような場合は、以下のようになります。
```cs
Console.WriteLine("\e[93mwarn\e[39m: something warning.");
```

なお、書式設定は`;`で組み合わせることができます。例えば以下は、下線＋明るい黄色＋背景赤で表示されます。
```cs
Console.WriteLine("\e[4;93;41mwarn\e[24;39;49m: something warning.");
```

C#の感覚だとかなり高カロリーな記述ですが、限られた箇所だけに書くならOKでしょうか。

## 利便性の高い方法を考える

何度か書く機会がある場合は、直書きだとつらいものがあります。そのため、より利便性の高い方法を考えてみます。（以降の内容は、冒頭で挙げた Kokuban を大きく参考とさせていただいています）

ここでは多彩な表現力を必要としない前提を出発点としているので、書式設定は単一のエスケープシーケンスで行うものとします。

### 書式を表す列挙体、ラッパー構造体の定義

まずエスケープシーケンスを列挙体として、番号を値と対応させ定義します。また、そのままだと扱いづらいのでラッパー構造体と、それを取得するためのクラスも定義します。

```cs
// エスケープシーケンスを表す列挙体
public enum Codes
{
    None            = 0,
    Bold            = 1,
    Underline       = 4,
    // ... 省略
}

// ラッパー構造体
public readonly record struct AnsiFormat(Codes Code);

// ラッパー構造体を取得するためのクラス
public static class Chalk
{
    public static AnsiFormat Bold => new(Codes.Bold);
    public static AnsiFormat Underline => new(Codes.Underline);
    // ... 省略
}
```

`Chalk`という名前については、Kokuban（及び、間接的に[chalk](https://github.com/chalk/chalk)）を参考にさせていただいています。

### 演算子オーバーロード

書式設定する文字列を指定するAPIを考えるにあたって、一例として、以下のような方法が挙げられます。（`"warn"`部分に書式設定）

```cs
// メソッド
Console.WriteLine($"{Chalk.BrightYellow.Encode("warn")}: something warning.");
// インデクサ
Console.WriteLine($"{Chalk.BrightYellow["warn"]}: something warning.");
// 拡張メソッド
Console.WriteLine($"{"warn".Encode(Chalk.BrightYellow)}: something warning.");
// 拡張メソッド（たくさん）
Console.WriteLine($"{"warn".ToBrightYellow()}: something warning.");
```

これらも利点はあると思いますが、演算子を用いるとノイズが少なく好ましいと感じます。

```cs
// 演算子
Console.WriteLine($"{Chalk.BrightYellow + "warn"}: something warning.");
```

これを行うため、ラッパー構造体に演算子オーバーロードを追加し、結果となる構造体を用意します。

```cs
public readonly record struct AnsiFormat(Codes Code)
{
    // 演算子オーバーロード
    public static AnsiFormatScope operator +(AnsiFormat format, string content) => new(format, content);
}

// 演算子の結果となる構造体
public readonly record struct AnsiFormatScope(AnsiFormat Format, string Content)
{
    public override string ToString()
    {
        // 書式設定された文字列を生成する処理
    }
}
```

これにより、例で言えば`Chalk.BrightYellow + "warn"`の部分が`AnsiFormatScope`構造体となり、`ToString()`を通じて書式設定された文字列を出力することができます。

### `ISpanFormattable`

`ToString()`での出力だと、補間文字列内では二重に文字列が生成されます。用途を考えると実際のところまったく問題ないですが、.NET 6 以降であれば`ISpanFormattable`を実装しておけばよいでしょう。

対象が`ISpanFormattable`の場合、補間文字列ハンドラは`ToString()`を呼ばず、バッファのスパンに直接書き込みます。

```cs
public readonly record struct AnsiFormatScope(AnsiFormat Format, string Content) : ISpanFormattable
{
    private int MaxLength => 2 + 3 + 1 + 5 + Content.Length;

    private string RestoreEscapeSequence => Format.Code switch
    {
        Codes.Bold => "\e[22m",
        Codes.Underline => "\e[24m",
        // ... 省略
    };

    public override string ToString() => $"{this}";

    string IFormattable.ToString(string? format, IFormatProvider? formatProvider) => ToString();

    // 補間文字列内で呼ばれる
    public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format = default, IFormatProvider? provider = null)
    {
        charsWritten = 0;

        // 補間文字列ハンドラは false を返すことでバッファサイズを拡大する
        if (destination.Length < MaxLength) return false;

        // $""は TryWriteInterpolatedStringHandler
        destination.TryWrite($"\e[{(int)Format.Code}m{Content}{RestoreEscapeSequence}", out charsWritten);

        return true;
    }
}
```

書き込む処理は以下の部分。

```cs
// $""は TryWriteInterpolatedStringHandler
destination.TryWrite($"\e[{(int)Format.Code}m{Content}{RestoreEscapeSequence}", out charsWritten);
```

ここでの`$""`は専用のハンドラ（`TryWriteInterpolatedStringHandler`）が走り、過程で文字列を生成しません。

----

これで、例えば以下のようにしてANSIエスケープシーケンスで書式設定された出力が可能です。（表示例は冒頭画像）

```cs
WindowsConsole.TryEnableVirtualTerminalProcessing();

Console.WriteLine($"{Chalk.BrightYellow + "warn"}: something warning.");
Console.WriteLine($"{Chalk.BgBrightRed + "error"}: error occured.");

Console.WriteLine();
foreach (var c in Enum.GetValues<Codes>())
{
    if (c is Codes.None) continue;

    AnsiFormat f = new(c);
    Console.Write(f + c.ToString());
}
```

## おわりに

以下の２点が合わさって、簡単な装飾でも、可能なら既存のライブラリを利用したほうがよいと感じました。

- Windowsでは有効にする手順が必要
- 入力や可読性から、直書きはつらめ

また、一定の利便性を確保を試みようとすると基本的には（劣化）再開発になりそうです。

とはいえ、自前で行う方法や利便性を高めるアプローチは学びでした。

----

参考として、今回製作したものを gist に置いておきます。
https://gist.github.com/aneuf-tech/fd459c46ed73c6dbec44e865cac8632f