CreateAssetで作成するファイルのパスに含めてはいけない文字

CreateAsset関数に渡すファイルパスのファイル名部分に、空白や一部記号など特定の文字が含まれていると例外が発生する件についてまとめました。

# 導入
AnimationClipを自動作成するEditor拡張を作成する過程で、空白や一部記号など特定の文字が含まれているファイルパスを指定した際に例外が発生する現象に遭遇しました。原因、回避方法が見つかったので共有します。

# 環境
- Windows 10
- Unity 2019.4.31f1
- Microsoft Visual Studio Community 2019 Version 16.11.16

# 事象
[CreateAsset](https://docs.unity3d.com/ja/2018.4/ScriptReference/AssetDatabase.CreateAsset.html)を用いると、第一引数に作成するオブジェクトを、第二引数にファイルパスを指定することで、アセットを作成することができます。

以下は、Buttonが押下されたら、ファイル名をファイルパスに合成してCreateAssetメソッドに渡し、アニメーションクリップを作成するエディタ拡張のコードになります。
<details><summary>サンプルコード</summary>


```C#:EditorExample
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

public class EditorExample : EditorWindow
{
    [MenuItem("EditorExample/FileExportTest")]
    private static void Create()
    {
        // 生成
        EditorExample window = GetWindow<EditorExample>("EditorExample");
    }

    // ファイルの名前部分を格納する変数
    string fileName = "";

    private void OnGUI()
    {
        /* エディターレイアウトここから */
        Color defaultColor = GUI.backgroundColor;
        using (new GUILayout.VerticalScope(EditorStyles.helpBox))
        {
            GUI.backgroundColor = Color.gray;
            using (new GUILayout.HorizontalScope(EditorStyles.toolbar))
            {
                GUILayout.Label("ファイル作成");
            }
            GUI.backgroundColor = defaultColor;
            /* エディターレイアウトここまで */

            // ファイル名入力テキストボックス
            fileName = EditorGUILayout.TextField("FileName:", fileName);

            if (GUILayout.Button("作成"))
            {
                if (fileName != null)
                {
                    ExportFile(fileName);
                }
            }
        }
    }

    public static void ExportFile(string fileName)
    {
        // オブジェクトを作成
        var animation = new AnimationClip();
        if (animation == null) return;

        // ファイル名をファイルパスに変換
        string exportPath = "Assets/EditorExample/Resources/" + fileName + ".anim";

        // アニメーションクリップを作成
        AssetDatabase.CreateAsset(animation, exportPath);
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/3e59359d-eca8-8f5c-732d-71cbae7bcae9.png)
このようなエディタが生成できます。

</details>



注目するのがExportFileメソッドです。CreateAssetを使用しています。
exportPathに引数として与えられた文字列（ファイル名）をパス・拡張子と結合した合成文字列を格納した後、CreateAssetに指定します。
```C# 
    public static void ExportFile(string fileName)
    {
        // オブジェクトを作成
        var animation = new AnimationClip();
        if (animation == null) return;

        // ファイル名をファイルパスに変換
        string exportPath = "Assets/EditorExample/Resources/" + fileName + ".anim";

        // アニメーションクリップを作成
        AssetDatabase.CreateAsset(animation, exportPath);
    }
```
このとき、特定のファイル名を使って完成したファイルパスをCreateAssetに渡すとエラーが発生します。

## ケース１：ファイル名に一部の記号が入っている場合
ファイル名に、**/**（スラッシュ）、**\\**（バックスラッシュ）、**?**（クエスチョン）、**\***（アスタリスク）など、一部の文字を指定するとエラーが発生する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/26e29b95-c80a-bc4a-0005-94cb4f3580b3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/c49da47a-476b-5da6-d67e-b1a49012a166.png)

## ケース２：ファイル名行頭が空白文字(全半角空白)の場合
ファイル名の**行頭**に空白が入っている場合、エラーが発生する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/e8b67c2c-213b-d810-1fbc-662d243a2838.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/0ef75ecb-cdd7-caf6-abfc-2dc7ea0c2c95.png)

# 原因
## 使用してはいけない文字が使用されている
「ファイル名やパス名に使用してはいけない文字」というものが存在しています。その文字を使ってファイルを作成しようとすると、例外が発生してしまうのです。
例えば、ファイル名とパスについて、```"``` ```<``` ```>``` ```|``` を**ファイル名とパス名**に含めることは**できない**。
また、パスについては```:``` ```?``` ```*``` ```/``` ```\```をパスに含めることができるが、**ファイル名には使用できない**。

今回のコードの場合、ファイル名の部分(.anim直前)を変更できるようにしているため、```"``` ```<``` ```>``` ```|``` ```:``` ```?``` ```*``` ```/``` ```\```これらを含めるとエラーになってしまいます。

> 次の点を除き、拡張文字セット (128 ~ 255) の Unicode 文字と文字など、現在のコード ページの任意の文字を名前に使用します。
>
>次の予約文字:
>+ < (より小さい)
>+ (より大きい)
>+ : (コロン)
>+ " (二重引用符)
>+ / (スラッシュ)
>+ \ (円記号)
>+ |(垂直バーまたはパイプ)
>+ ? (疑問符)
>+ \* (アスタリスク)
>+ 整数値 0。ASCII NUL 文字と呼ばれることもあります。
>
>- 整数表現が 1 ~ 31 の範囲にある文字 (これらの文字が許可されている代替データ ストリームを除く)。 ファイル ストリームの詳細については、「ファイルのストリーム」を参照してください。
>
>+ ターゲット ファイル システムで許可されていないその他の文字。

https://docs.microsoft.com/ja-jp/windows/win32/fileio/naming-a-file#:~:text=%E6%AC%A1%E3%81%AE%E7%82%B9%E3%82%92%E9%99%A4%E3%81%8D%E3%80%81%E6%8B%A1%E5%BC%B5%E6%96%87%E5%AD%97%E3%82%BB%E3%83%83%E3%83%88%20(128%20~%20255)%20%E3%81%AE%20Unicode%20%E6%96%87%E5%AD%97%E3%81%A8%E6%96%87%E5%AD%97%E3%81%AA%E3%81%A9%E3%80%81%E7%8F%BE%E5%9C%A8%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89%20%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE%E4%BB%BB%E6%84%8F%E3%81%AE%E6%96%87%E5%AD%97%E3%82%92%E5%90%8D%E5%89%8D%E3%81%AB%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%BE%E3%81%99%E3%80%82

## 誤った空白の使い方がされている
空白をファイルパスに含めてしまうと、そこでパスが分割されてしまうため、例外が出力されたものと思われます。[^1]
[^1]:https://atmarkit.itmedia.co.jp/bbs/phpBB/viewtopic.php?topic=18081&forum=7

ファイル名またはディレクトリ名をスペースまたはピリオドで終了することも禁止されています。
**が、** 今回のコードでは末尾に空白が入っていても作成できてしまいました。（末尾の空白は無視される？）
> ファイル名またはディレクトリ名をスペースまたはピリオドで終了しないでください。 基になるファイル システムではこのような名前がサポートされる場合がありますが、Windows シェルとユーザー インターフェイスではサポートされません。 ただし、名前の最初の文字としてピリオドを指定することもできます。 たとえば、".temp" です。

https://docs.microsoft.com/ja-jp/windows/win32/fileio/naming-a-file#:~:text=%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D%E3%81%BE%E3%81%9F%E3%81%AF%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83%88%E3%83%AA%E5%90%8D%E3%82%92%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9%E3%81%BE%E3%81%9F%E3%81%AF%E3%83%94%E3%83%AA%E3%82%AA%E3%83%89%E3%81%A7%E7%B5%82%E4%BA%86%E3%81%97%E3%81%AA%E3%81%84%E3%81%A7%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84%E3%80%82

# 解決方法
使用不可文字・空白のどちらが含まれていても、**正規表現**と、**IsMatch関数**または**Replace関数**を用いて解決することができました。
- 使用不可文字・空白パターンを**検知したらログを出力し、終了する**
- 使用不可文字・空白パターンを**nullに置き換えて除去する**
の２パターンを紹介します。

## 使用不可文字・空白パターンを検知したらログを出力し、終了する
ExportFileメソッドに追記します。
```C#:EditorExample
    public static void ExportFile(string fileName)
    {
        var animation = new AnimationClip();
        if (animation == null) return;
        
        /* 追記ここから */
        // 使えない文字(" < > | : ? * / \)を含んでいないかチェック
        if (System.Text.RegularExpressions.Regex.IsMatch(fileName,
                                @"(^[\s]+|[\s]+$)|(""|<|>|\||:|\?|\*|/|\\)"))
        {
            // 使えないファイル名がある
            Debug.Log("Failed to create asset.");
            return;
        }
        /* 追記ここまで */

        string exportPath = "Assets/EditorExample/Resources/" + fileName + ".anim";

        AssetDatabase.CreateAsset(animation, exportPath);
        Debug.Log("Created an Asset in "+ exportPath +" .");
    }
```
[**Regex.IsMatch**関数](https://docs.microsoft.com/ja-jp/dotnet/api/system.text.regularexpressions.regex.ismatch?view=net-6.0)は、正規表現と一致する対象が入力文字列内で見つかったかどうかをチェックしてくれるメソッドです。**正規表現とマッチした場合trueを、それ以外の場合はfalseを返します。**
```C#
        // 使えない文字(" < > | : ? * / \)を含んでいないかチェック
        if (System.Text.RegularExpressions.Regex.IsMatch(fileName,
                                @"(^[\s]+|[\s]+$)|(""|<|>|\||:|\?|\*|/|\\)"))
        {
            ...
```

ファイル名（filename）に、使えない文字(" < > | : ? * / \)を含んでいないか、前後に空白が入っていないかをチェックしたいので、正規表現を組み立てると、
```C#
@"(""|<|>|\||:|\?|\*|/|\\)|(^[\s]+|[\s]+$)"
```
となります。
後半の[\s]は、文字列では空白(0x20)を意味しますが、正規表現においてはタブなどを含む空白文字全般にマッチするメタ文字列になるそうです。[^2]。
[^2]:https://docs.ruby-lang.org/ja/latest/doc/spec=2fregexp.html#:~:text=%E3%80%8C%5Cs%E3%80%8D%E3%81%AF%E6%96%87%E5%AD%97%E5%88%97,%E3%81%99%E3%82%8B%E3%83%A1%E3%82%BF%E6%96%87%E5%AD%97%E5%88%97%E3%81%A7%E3%81%99%E3%80%82

:::note warn
- メタキャラクタを必ず「 \ 」でエスケープすること
- ダブルクォーテーションはエスケープできないため「""」と記述すること
に注意してください。
:::
（初めてしっかりと正規表現を分析したので苦労しました）
メソッドの修正により、使用不可文字・空白パターンがファイル名に含まれていた場合、失敗した旨をログに出力し処理を終了させることができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/950371f6-b20d-77d8-214f-7ad2cbdd5b5d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/6172f6a2-a0bc-9ad9-08cb-fd2090d8caac.png)

## 使用不可文字・空白パターンをnullに置き換えて除去する
ExportFileメソッドに追記します。
```C#:ExportFile
    public static void ExportFile(string fileName)
    {
        var animation = new AnimationClip();
        if (animation == null) return;
        
        fileName = System.Text.RegularExpressions.Regex.Replace(fileName
                               , @"(^[\s]+|[\s]+$)|(<|>|\||:|\?|\*|/|\\|"")"
                               , "");

        string exportPath = "Assets/EditorExample/Resources/" + fileName + ".anim";

        AssetDatabase.CreateAsset(animation, exportPath);
        Debug.Log("Created an Asset in "+ exportPath +" .");
    }
```
[**Regex.Replace**関数](https://docs.microsoft.com/ja-jp/dotnet/api/system.text.regularexpressions.regex.replace?view=net-6.0)は、指定した入力文字列内で**正規表現パターンに一致する文字列を、指定した置換文字列に置き換え**ます。第一引数に文字列を、第二引数に正規表現パターンを、第三引数に置き換える文字列を指定します。

第二引数の正規表現は前述のIsMatch関数による検知に使用したものと同じです。（使用不可文字と、文字列前後の空白がマッチ対象となります。）
メソッドの修正により、使用不可文字・空白パターンをファイル名に含んでいても、対象文字を削除してファイルを作成することができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/2232d5bb-b7ae-8ed0-00cb-0ebad2437e2c.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/98b75f8b-7d7a-0402-7459-23f743bc7a63.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2302293/9975e992-39e4-6647-4de4-a0759d18710f.png)

# おわりに
初めて記事を書き上げることができました。誰かの役に立てばうれしいです。

某VRSNSでUnityやEditor拡張に初めて触れ、面倒な処理作業の自動化をできることの魅力にとりつかれました。独学ではありますが将来的になにか便利ツールとしてリリースできればいいなと考えながら学習をしています。これらのアウトプットが将来に役立つかは正直わからないのですが、継続してプログラムを書き続けて探求していき、なにか得るものがあればよいなと考えています。

# 参考記事

https://www.curict.com/item/0a/0a33f42.html

https://www.fenet.jp/dotnet/column/language/5833/

https://docs.microsoft.com/ja-jp/dotnet/api/system.text.regularexpressions.regex.ismatch?view=net-6.0

https://docs.microsoft.com/ja-jp/dotnet/api/system.text.regularexpressions.regex.replace?view=net-6.0

https://docs.ruby-lang.org/ja/latest/doc/spec=2fregexp.html#:~:text=%E3%80%8C%5Cs%E3%80%8D%E3%81%AF%E6%96%87%E5%AD%97%E5%88%97,%E3%81%99%E3%82%8B%E3%83%A1%E3%82%BF%E6%96%87%E5%AD%97%E5%88%97%E3%81%A7%E3%81%99%E3%80%82