C#でプログラミングの練習がてらCSVリーダを書いた話

#はじめに

[TextFieldParser](https://docs.microsoft.com/ja-jp/dotnet/api/microsoft.visualbasic.fileio.textfieldparser?view=netframework-4.7.2)や[CSVHelper](https://joshclose.github.io/CsvHelper/)を追加で入れるのが色々面倒な環境で作業をしている際に、テスト時のデータチェック用 兼 勉強用 兼 練習 兼 遊び目的でCSVファイルのパース処理を組んだ記録です。
初投稿なので、さらに投稿の練習も兼ねて。

#環境
.Net Framework 4.5 (C# 5)

#CSVの仕様
[Comma-Separated Values - ウィキペディア](https://ja.wikipedia.org/wiki/Comma-Separated_Values)

要約すると、

- カンマ(,)でデータが区切られている
- データ内に以下のどれかがあれば、データをダブルクォートで囲む
    - カンマ
    - 改行
    - ダブルクォート
- データ内にダブルクォートがあれば、それを二重にする
- 以上に関係なく、各データはダブルクォートで囲んでもいい

使うシステムで仕様が違う場合があるとのことですが、おそらくここまでが基本仕様とのこと。

#読み取りの指針
以下の処理ができれば読み取りできるはず、という点を書く前に大まかに整理。

- データの先頭がダブルクォートだったら、それ以降でペアになっていないダブルクォートを探して、そこまでを1データとする 
- データ自体にダブルクォートがあれば、二重になっているのを1つに変換する
- データの先頭がダブルクォートでなければ、次のカンマまでを1データとする
- 行の先頭、末尾にカンマがあれば、その前後に空文字データがあると考える
- 2回以上連続でカンマがあれば、そこに空文字データがあると考える
- 空行は、空文字データが1つある行と考える

#実際のメソッド

```csharp

// File.ReadLines(string path)辺りを引数で渡すつもりで作成
public IEnumerable<List<string>> ReadCsvData(IEnumerable<string> data)
{
    // 返却用リスト
    List<string> retLine = new List<string>();
    // データの改行有無フラグ
    bool hasNewLine = false;
   
    foreach(string line in data)
    {
        int iStart = 0;

        // 前の読み取り行に改行付きデータがあれば、ダブルクォートの終点探し
        if (hasNewLine)
        {
            retLine[retLine.Count - 1] += Environment.NewLine;
            int iEnd = SearchCloseQuot(line, iStart);
            if (iEnd == -1)
            {
                retLine[retLine.Count - 1] += line.Replace("\"\"", "\"");
                continue;
            }
            retLine[retLine.Count - 1] += line.Substring(iStart, iEnd).Replace("\"\"", "\""); 
            hasNewLine = false;

            iStart = line.IndexOf(',', ++iEnd);

            if(iStart == -1)
            {
                yield return retLine;
                retLine = new List<string>();
                continue;
            }

            iStart++;
        }
        
        // 行始まりか、前行からの改行データ終わりから読み取り開始
        for (; iStart < line.Length; iStart++)
        {
            //始点がダブルクォートなら、終点のダブルクォートの探索
            if(line[iStart] == '"')
            {
                int iEnd = SearchCloseQuot(line, iStart + 1);
                if (iEnd == -1)
                {
                    retLine.Add(line.Substring(iStart));
                    hasNewLine = true;
                    break;
                }
                retLine.Add(line.Substring(iStart + 1, iEnd - iStart - 1).Replace("\"\"", "\""));
                if(iEnd == line.Length - 1)
                {
                    break;
                }
                else
                {
                    iStart = line.IndexOf(',', iEnd + 1);
                    continue;
                }
            }
            // 始点がカンマなら、空文字データがあるとして処理
            else if (line[iStart] == ',')
            {
                retLine.Add("");
                continue;
            }
            // 始点がそれ以外なら、次のカンマを探索
            else
            {
                int iEnd = line.IndexOf(',', iStart);
                if(iEnd == -1)
                {
                    retLine.Add(line.Substring(iStart).Trim());
                    break;
                }
                else
                {
                    retLine.Add(line.Substring(iStart, iEnd - iStart));
                    iStart = iEnd;
                    continue;
                }
            }
        }

        // 行内のデータに改行がなければ、リストを返却してリセット
        if (!hasNewLine)
        {
            if (line.Length == 0 || line[line.Length - 1] == ',')
            {
                retLine.Add("");
            }
            yield return retLine;
            retLine = new List<string>();
        }
    }

    // 読み取りが終わった時に改行があれば異常処理
    if (hasNewLine)
        throw new InvalidDataException();
}

// データ終端のダブルクォートを探すサブ関数
private int SearchCloseQuot(string data, int startIndex)
{
    for (int i = startIndex; i < data.Length; i += 2)
    {
        i = data.IndexOf('"', i);
        if (i == data.Length - 1 || data[i + 1] != '"' || i == -1)
        {
            return i;
        }
    }
    return -1;
}

```

#使ってみた
テストデータ：

```csv
a,b,cd,e
,f,,g,h,

"i,",j,k
"lm"",n",op,"qrs",
"tu","vw""
""x",yz
```

実行結果：
(データのない箇所はハイフン)

|0|1|2|3|4|5|
|:-----|------|------|------|------|-----:|
|a|b|cd|e|-|-|
||f||g|h||
||-|-|-|-|-|
|i,|j|k|-|-|-|
|lm",n|op|qrs||-|-|
|tu|vw"<br>"x|yz|-|-|-|

#補記

いったん例は割愛しますが、前述のTextFieldParserクラスでの読み取りには

- データの先頭、末尾にスペースがあると、ダブルクォートで囲っていてもTrimされる
- 空白行(サンプルデータの3行目)はスキップして読み取る

などの特徴があって、使っていたデータだと不都合がありました。結果として組んでおいてよかったのかもしれない。