PHPでCSVのparserを書いてみた

# PHPでCSVのparserを書いてみた

## 動機

元々PHPでは fgetcsv() や str_getcsv() でCSVをparse出来るが、LC_CTYPEの設定次第では日本語の混じったCSVなどでparseが失敗する事がある。PHP5.xで動いていたものがPHP7にしてからおかしくなることもあるとか。
長大なCSVファイルの読み込みであれば fgetcsv() を使うのが良いだろうが、短いCSVをぱっとparseしたいだけなら自作の関数で足りるのではないかということで実装してみた。

## 実装

```php
// 実装例1: 正規表現を使わない実装
function csv_parse($str) {
	$len = strlen($str);
	$rows = array();
	$row = array();
    // \r\n, \r, \n のうち最初に出現したものを改行記号とする
	$l="\n";$xm=$len;foreach(array("\r\n","\r","\n") as $ln) {if (($x=strpos($str, $ln))!==false && $x<$xm) {$l=$ln;$xm=$x;}}
	$m = strlen($l);
	for($i=0,$c=$r=-1; $i<$len; $i++) {
		if (substr($str, $i, 1) === '"') { //quoted
			for($j=0, $q = $i + 1; $q < $len; $j++, $q++) { //閉quotを探す
				$q = ($q = strpos($str, '"', $q)) === false ? $len + 1 : $q;       //quotの位置、無いなら末尾まで
				if (substr($str, ++$q, 1) !== '"') {break;} //""なら継続
			}
			$v = substr($str, $i + 1, $q - $i - 2);
			$i = $q;
			$row[] = $j > 0 ? strtr($v,array('""'=>'"')) : $v;
		} else { //not quoted
			if ($c < $i) {$c = ($c = strpos($str, ",", $i)) === false ? $len : $c;} //直近のカンマ位置と
			if ($r < $i) {$r = ($r = strpos($str, $l, $i)) === false ? $len : $r;} //直近の改行位置を調べ
			$il = ($c < $r ? $c : $r) - $i;
			$row[] = substr($str, $i, $il); //そこまでを値とする
			$i = $il + $i;
		}
		if ($i === $r || $l === substr($str, $i, $m > 1 ? $m : 1)) {
			$rows[] = $row;
			$row = array();
			$i += $m - 1;
		}
	}
	if (substr($str, $i-1, 1) === ',') {$row[] = '';} //,で終わる
	if (count($row) > 0) {$rows[] = $row;}
	if (substr($str, $i-1, $m) === $l) {$rows[] = array();} //最後の改行を無視する場合はコメントアウト
	return $rows;
}

// 実装例2: 正規表現を使えば短くできる
function csv_parse_re($str) {
	$re = '/"(?:[^"]|"")*"|"(?:[^"]|"")*$|[^,\r\n]+|,+|\r?\n|\r/m';
	$r = array(array(""));
	if (!$str) {return array();}
	$offset = 0;
	while(preg_match($re, $str, $m, 0, $offset)) {
		$offset += strlen($m[0]);
		if (($c = substr($m[0], 0, 1)) === ',') {
			for($c = strlen($m[0]); $c > 0; $c--) {$r[count($r)-1][]='';}
			continue;
		}
		if ($c === "\n" || $c === "\r\n" || $c === "\r") {$r[] = array('');continue;}
		$r[$n=count($r)-1][count($r[$n])-1] = $c === '"' ? substr(strtr($m[0],array('""'=>'"')),1,-1) : $m[0];
	}
	return $r;
}
```

[javascriptでCSVのparser/stringifierを書いてみた](https://qiita.com/weal/items/5aa94235c40d60ef2f0c) の String.prototype.indexOf を利用した実装/正規表現を利用した実装をPHP向けに書き換えただけ。



## テスト

```php
echo var_dump(csv_parse("a,b,c,\n\"d,e\",\"\"\"F\"\"\"\nあ,\"い,う,\nえ\",お"));
echo var_dump(csv_parse_re("a,b,c,\n\"d,e\",\"\"\"F\"\"\"\nあ,\"い,う,\nえ\",お"));
```

```php
array(3) {
  [0]=>
  array(4) {
    [0]=>
    string(1) "a"
    [1]=>
    string(1) "b"
    [2]=>
    string(1) "c"
    [3]=>
    string(0) ""
  }
  [1]=>
  array(2) {
    [0]=>
    string(3) "d,e"
    [1]=>
    string(3) ""F""
  }
  [2]=>
  array(3) {
    [0]=>
    string(3) "あ"
    [1]=>
    string(13) "い,う,
え"
    [2]=>
    string(3) "お"
  }
}
```

var_dump() の出力がちょっと怪しいがちゃんとparse出来ているはず

## fgetcsv() を利用した実装

ちなみにfgetcsv()を使う場合は

```php
setlocale(LC_CTYPE, 'C'); //LC_CTYPEが'Japanese_Japan.932'だったりするとおかしくなる

function csv_parse($str) {
	$fp = tmpfile();
	if (fwrite($fp, $str) === false) {fclose($fp);return;}
	fseek($fp, 0);
	$data = array();
	while($row = fgetcsv($fp)) {$data[] = $row;}
	fclose($fp);
	return $data;
}
```

で同じ結果になる。
str_getcsv() を使った実装は、行の分割がややこしそうなのでパス


## 参考
* [PHP: fgetcsv()](http://php.net/manual/ja/function.fgetcsv.php)
* [PHP: str_getcsv()](http://php.net/manual/ja/function.str-getcsv.php)
* [なぜPHPのfgetcsv()はロカール依存という糞仕様なのか](http://d.hatena.ne.jp/moriyoshi/20090317/1237269553)