From 416352f5fb461a4d37ac4461da17b1a80252f946 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 4 Nov 2026 11:42:25 +0100 Subject: [PATCH 1/7] fix: parse multiline fields in CSVArray::toArray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read records with fgetcsv instead of splitting lines first, so quoted fields may contain line breaks. 🤖 Generated with Claude Code --- src/CSVArray.php | 37 ++++++++++++++++++------------------- tests/CSVArrayTest.php | 7 +++++-- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/CSVArray.php b/src/CSVArray.php index 350e4cd2..dbdd2ff7 100644 --- a/src/CSVArray.php +++ b/src/CSVArray.php @@ -11,40 +11,39 @@ class CSVArray { public const DEFAULT_EMPTY_VALUE = ''; - /** - * TODO: fix parsing multiline-content in csv. - * - * @return array> - */ + /** @return array> */ public static function toArray(string $csv, string $delimiter = ';', string $enclosure = '"', string $escape = '\\'): array { - /** @var array> $result */ - $result = []; - - $lines = StringUtil::splitLines($csv); + $stream = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($stream, $csv); + \Safe\rewind($stream); // The first row of the CSV usually contains the column headers // We remove it so it does not show up in the result - $firstLine = array_shift($lines); - if (is_null($firstLine)) { - throw new \Exception('Missing column headers.'); + $columnHeaders = fgetcsv($stream, 0, $delimiter, $enclosure, $escape); // @phpstan-ignore-line theCodingMachineSafe.function (Safe\fgetcsv is missing in Safe 1.x) + if ($columnHeaders === false) { + \Safe\fclose($stream); + + return []; } - /** @var array $columnHeaders */ - $columnHeaders = str_getcsv($firstLine, $delimiter, $enclosure, $escape); + /** @var array> $result */ + $result = []; - foreach ($lines as $index => $line) { - if (! StringUtil::hasContent($line)) { + $index = 0; + while (($entries = fgetcsv($stream, 0, $delimiter, $enclosure, $escape)) !== false) { // @phpstan-ignore-line theCodingMachineSafe.function (Safe\fgetcsv is missing in Safe 1.x) + ++$index; + if (count($entries) === 1 && ! StringUtil::hasContent($entries[0])) { continue; } - /** @var array $entries */ - $entries = str_getcsv($line, $delimiter, $enclosure, $escape); foreach ($columnHeaders as $columnIndex => $columnName) { - $result[$index + 1][$columnName] = $entries[$columnIndex] ?? self::DEFAULT_EMPTY_VALUE; + $result[$index][$columnName] = $entries[$columnIndex] ?? self::DEFAULT_EMPTY_VALUE; } } + \Safe\fclose($stream); + return $result; } diff --git a/tests/CSVArrayTest.php b/tests/CSVArrayTest.php index 9b6e79e3..f51a684e 100644 --- a/tests/CSVArrayTest.php +++ b/tests/CSVArrayTest.php @@ -163,6 +163,11 @@ public function testToArrayDefaultsMissingColumnsToEmptyStrings(): void ); } + public function testToArrayReturnsEmptyArrayForEmptyCSV(): void + { + self::assertSame([], CSVArray::toArray('')); + } + public function testHandlesMultilineStrings(): void { $multilineCsv = << Date: Wed, 4 Nov 2026 11:53:24 +0100 Subject: [PATCH 2/7] fix: type fgetcsv records for older PHPStan stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/CSVArray.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/CSVArray.php b/src/CSVArray.php index dbdd2ff7..dc9ca395 100644 --- a/src/CSVArray.php +++ b/src/CSVArray.php @@ -20,6 +20,7 @@ public static function toArray(string $csv, string $delimiter = ';', string $enc // The first row of the CSV usually contains the column headers // We remove it so it does not show up in the result + /** @var array|false $columnHeaders */ $columnHeaders = fgetcsv($stream, 0, $delimiter, $enclosure, $escape); // @phpstan-ignore-line theCodingMachineSafe.function (Safe\fgetcsv is missing in Safe 1.x) if ($columnHeaders === false) { \Safe\fclose($stream); @@ -31,14 +32,20 @@ public static function toArray(string $csv, string $delimiter = ';', string $enc $result = []; $index = 0; - while (($entries = fgetcsv($stream, 0, $delimiter, $enclosure, $escape)) !== false) { // @phpstan-ignore-line theCodingMachineSafe.function (Safe\fgetcsv is missing in Safe 1.x) + while (true) { + /** @var array|false $entries */ + $entries = fgetcsv($stream, 0, $delimiter, $enclosure, $escape); // @phpstan-ignore-line theCodingMachineSafe.function (Safe\fgetcsv is missing in Safe 1.x) + if ($entries === false) { + break; + } + ++$index; if (count($entries) === 1 && ! StringUtil::hasContent($entries[0])) { continue; } foreach ($columnHeaders as $columnIndex => $columnName) { - $result[$index][$columnName] = $entries[$columnIndex] ?? self::DEFAULT_EMPTY_VALUE; + $result[$index][(string) $columnName] = $entries[$columnIndex] ?? self::DEFAULT_EMPTY_VALUE; } } From d39ee7128d170ee1ef0a6d221045f76f0aaca2ca Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 4 Nov 2026 12:46:02 +0100 Subject: [PATCH 3/7] fix: parse CSV with CR-only line endings in CSVArray::toArray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fgetcsv ends records only at \n, so input whose lines end in a lone \r, such as classic Mac exports, parsed to no rows. Input without any \n now has its \r normalized to \n before parsing. 🤖 Generated with Claude Code --- src/CSVArray.php | 5 +++++ tests/CSVArrayTest.php | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/CSVArray.php b/src/CSVArray.php index dc9ca395..421d851f 100644 --- a/src/CSVArray.php +++ b/src/CSVArray.php @@ -14,6 +14,11 @@ class CSVArray /** @return array> */ public static function toArray(string $csv, string $delimiter = ';', string $enclosure = '"', string $escape = '\\'): array { + // fgetcsv ends records only at \n + if (strpos($csv, "\n") === false) { + $csv = str_replace("\r", "\n", $csv); + } + $stream = \Safe\fopen('php://memory', 'r+'); \Safe\fwrite($stream, $csv); \Safe\rewind($stream); diff --git a/tests/CSVArrayTest.php b/tests/CSVArrayTest.php index f51a684e..9759107d 100644 --- a/tests/CSVArrayTest.php +++ b/tests/CSVArrayTest.php @@ -163,6 +163,17 @@ public function testToArrayDefaultsMissingColumnsToEmptyStrings(): void ); } + public function testToArrayHandlesCarriageReturnLineEndings(): void + { + self::assertSame( + [ + 1 => ['a' => 'x', 'b' => 'y'], + 2 => ['a' => '1', 'b' => '2'], + ], + CSVArray::toArray("a;b\rx;y\r1;2\r") + ); + } + public function testToArrayReturnsEmptyArrayForEmptyCSV(): void { self::assertSame([], CSVArray::toArray('')); From 98438768f3cadc6f92935e84db361b90d3e8533f Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 4 Nov 2026 12:57:35 +0100 Subject: [PATCH 4/7] fix: join lines into records instead of reading them with fgetcsv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fgetcsv ends records only at \n and returns [null] for blank lines, which is indistinguishable from an enclosed empty value. That dropped single-column rows with "", merged rows with mixed line breaks and broke CR-only input with an enclosed \n. Keep splitting at \r\n, \n and \r as before, but join lines while an enclosed field is open, then parse each record with str_getcsv. 🤖 Generated with Claude Code --- src/CSVArray.php | 83 ++++++++++++++++++++++++++++-------------- tests/CSVArrayTest.php | 50 ++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 32 deletions(-) diff --git a/src/CSVArray.php b/src/CSVArray.php index 421d851f..86660bbe 100644 --- a/src/CSVArray.php +++ b/src/CSVArray.php @@ -14,49 +14,78 @@ class CSVArray /** @return array> */ public static function toArray(string $csv, string $delimiter = ';', string $enclosure = '"', string $escape = '\\'): array { - // fgetcsv ends records only at \n - if (strpos($csv, "\n") === false) { - $csv = str_replace("\r", "\n", $csv); - } + /** @var array> $result */ + $result = []; - $stream = \Safe\fopen('php://memory', 'r+'); - \Safe\fwrite($stream, $csv); - \Safe\rewind($stream); + $records = self::splitRecords($csv, $enclosure, $escape); // The first row of the CSV usually contains the column headers // We remove it so it does not show up in the result - /** @var array|false $columnHeaders */ - $columnHeaders = fgetcsv($stream, 0, $delimiter, $enclosure, $escape); // @phpstan-ignore-line theCodingMachineSafe.function (Safe\fgetcsv is missing in Safe 1.x) - if ($columnHeaders === false) { - \Safe\fclose($stream); - - return []; + $headerRecord = array_shift($records); + if (is_null($headerRecord)) { + throw new \Exception('Missing column headers.'); } - /** @var array> $result */ - $result = []; + /** @var array $columnHeaders */ + $columnHeaders = str_getcsv($headerRecord, $delimiter, $enclosure, $escape); - $index = 0; - while (true) { - /** @var array|false $entries */ - $entries = fgetcsv($stream, 0, $delimiter, $enclosure, $escape); // @phpstan-ignore-line theCodingMachineSafe.function (Safe\fgetcsv is missing in Safe 1.x) - if ($entries === false) { - break; + foreach ($records as $index => $record) { + if (! StringUtil::hasContent($record)) { + continue; } - ++$index; - if (count($entries) === 1 && ! StringUtil::hasContent($entries[0])) { + /** @var array $entries */ + $entries = str_getcsv($record, $delimiter, $enclosure, $escape); + foreach ($columnHeaders as $columnIndex => $columnName) { + $result[$index + 1][$columnName] = $entries[$columnIndex] ?? self::DEFAULT_EMPTY_VALUE; + } + } + + return $result; + } + + /** @return array */ + private static function splitRecords(string $csv, string $enclosure, string $escape): array + { + $records = []; + $record = ''; + $isInsideEnclosure = false; + + foreach (\Safe\preg_split("/(\r\n|\n|\r)/", $csv, -1, PREG_SPLIT_DELIM_CAPTURE) as $index => $part) { + assert(is_string($part)); + + $isLineBreak = $index % 2 === 1; + if ($isLineBreak && ! $isInsideEnclosure) { + $records[] = $record; + $record = ''; + continue; } - foreach ($columnHeaders as $columnIndex => $columnName) { - $result[$index][(string) $columnName] = $entries[$columnIndex] ?? self::DEFAULT_EMPTY_VALUE; + $record .= $part; + if (! $isLineBreak) { + $isInsideEnclosure = self::isInsideEnclosureAfter($part, $isInsideEnclosure, $enclosure, $escape); } } - \Safe\fclose($stream); + $records[] = $record; - return $result; + return $records; + } + + private static function isInsideEnclosureAfter(string $text, bool $isInsideEnclosure, string $enclosure, string $escape): bool + { + $length = strlen($text); + for ($position = 0; $position < $length; ++$position) { + $character = $text[$position]; + if ($isInsideEnclosure && $character === $escape && $escape !== $enclosure) { + ++$position; + } elseif ($character === $enclosure) { + $isInsideEnclosure = ! $isInsideEnclosure; + } + } + + return $isInsideEnclosure; } /** @param iterable> $data */ diff --git a/tests/CSVArrayTest.php b/tests/CSVArrayTest.php index 9759107d..d7cad706 100644 --- a/tests/CSVArrayTest.php +++ b/tests/CSVArrayTest.php @@ -163,18 +163,58 @@ public function testToArrayDefaultsMissingColumnsToEmptyStrings(): void ); } - public function testToArrayHandlesCarriageReturnLineEndings(): void + /** @return iterable>}> */ + public static function lineBreakVariants(): iterable { - self::assertSame( + yield 'carriage return only' => [ + "a;b\rx;y\r1;2\r", [ 1 => ['a' => 'x', 'b' => 'y'], 2 => ['a' => '1', 'b' => '2'], ], - CSVArray::toArray("a;b\rx;y\r1;2\r") - ); + ]; + yield 'mixed line breaks' => [ + "a;b\rx;y\n1;2\r\n", + [ + 1 => ['a' => 'x', 'b' => 'y'], + 2 => ['a' => '1', 'b' => '2'], + ], + ]; + yield 'carriage return only with line feed in enclosed field' => [ + "a;b\rx;\"hello\nworld\"\r1;2\r", + [ + 1 => ['a' => 'x', 'b' => "hello\nworld"], + 2 => ['a' => '1', 'b' => '2'], + ], + ]; + yield 'enclosed empty value in single column' => [ + "name\r\n\"\"\r\nBob\r\n", + [ + 1 => ['name' => ''], + 2 => ['name' => 'Bob'], + ], + ]; + yield 'escaped enclosure in multiline field' => [ + "a;b\r\nx;\"say \\\"hi\r\nthere\"\r\n1;2\r\n", + [ + 1 => ['a' => 'x', 'b' => "say \\\"hi\r\nthere"], + 2 => ['a' => '1', 'b' => '2'], + ], + ]; + } + + /** + * @dataProvider lineBreakVariants + * + * @param array> $array + */ + #[DataProvider('lineBreakVariants')] + public function testToArrayHandlesLineBreakVariants(string $csv, array $array): void + { + self::assertSame($array, CSVArray::toArray($csv)); } - public function testToArrayReturnsEmptyArrayForEmptyCSV(): void + public function testToArrayYieldsNoRecordsForEmptyCSV(): void { self::assertSame([], CSVArray::toArray('')); } From 1f56290817827a97a154ed882b85c0a89c2d62d8 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 4 Nov 2026 13:00:47 +0100 Subject: [PATCH 5/7] fix: open enclosures only at the start of a field in CSVArray::toArray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stray enclosure inside an unenclosed field, such as an inch mark, opened an enclosure that never closed and merged the following rows. Track field state like str_getcsv does: an enclosure opens only at the start of a field, after optional spaces and tabs. 🤖 Generated with Claude Code --- src/CSVArray.php | 46 +++++++++++++++++++++++++++++----------- tests/CSVArrayTest.php | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/CSVArray.php b/src/CSVArray.php index 86660bbe..5151e42c 100644 --- a/src/CSVArray.php +++ b/src/CSVArray.php @@ -11,13 +11,18 @@ class CSVArray { public const DEFAULT_EMPTY_VALUE = ''; + private const FIELD_START = 0; + private const FIELD_UNENCLOSED = 1; + private const FIELD_ENCLOSED = 2; + private const FIELD_AFTER_ENCLOSURE = 3; + /** @return array> */ public static function toArray(string $csv, string $delimiter = ';', string $enclosure = '"', string $escape = '\\'): array { /** @var array> $result */ $result = []; - $records = self::splitRecords($csv, $enclosure, $escape); + $records = self::splitRecords($csv, $delimiter, $enclosure, $escape); // The first row of the CSV usually contains the column headers // We remove it so it does not show up in the result @@ -45,26 +50,27 @@ public static function toArray(string $csv, string $delimiter = ';', string $enc } /** @return array */ - private static function splitRecords(string $csv, string $enclosure, string $escape): array + private static function splitRecords(string $csv, string $delimiter, string $enclosure, string $escape): array { $records = []; $record = ''; - $isInsideEnclosure = false; + $fieldState = self::FIELD_START; foreach (\Safe\preg_split("/(\r\n|\n|\r)/", $csv, -1, PREG_SPLIT_DELIM_CAPTURE) as $index => $part) { - assert(is_string($part)); + assert(is_string($part), 'preg_split returns strings.'); $isLineBreak = $index % 2 === 1; - if ($isLineBreak && ! $isInsideEnclosure) { + if ($isLineBreak && $fieldState !== self::FIELD_ENCLOSED) { $records[] = $record; $record = ''; + $fieldState = self::FIELD_START; continue; } $record .= $part; if (! $isLineBreak) { - $isInsideEnclosure = self::isInsideEnclosureAfter($part, $isInsideEnclosure, $enclosure, $escape); + $fieldState = self::fieldStateAfter($part, $fieldState, $delimiter, $enclosure, $escape); } } @@ -73,19 +79,35 @@ private static function splitRecords(string $csv, string $enclosure, string $esc return $records; } - private static function isInsideEnclosureAfter(string $text, bool $isInsideEnclosure, string $enclosure, string $escape): bool + /** Follows the quoting rules of str_getcsv: an enclosure only opens at the start of a field. */ + private static function fieldStateAfter(string $text, int $fieldState, string $delimiter, string $enclosure, string $escape): int { $length = strlen($text); for ($position = 0; $position < $length; ++$position) { $character = $text[$position]; - if ($isInsideEnclosure && $character === $escape && $escape !== $enclosure) { - ++$position; - } elseif ($character === $enclosure) { - $isInsideEnclosure = ! $isInsideEnclosure; + + if ($fieldState === self::FIELD_ENCLOSED) { + if ($character === $escape && $escape !== $enclosure) { + ++$position; + } elseif ($character === $enclosure) { + if (($text[$position + 1] ?? '') === $enclosure) { + ++$position; + } else { + $fieldState = self::FIELD_AFTER_ENCLOSURE; + } + } + } elseif ($character === $delimiter) { + $fieldState = self::FIELD_START; + } elseif ($fieldState === self::FIELD_START) { + if ($character === $enclosure) { + $fieldState = self::FIELD_ENCLOSED; + } elseif ($character !== ' ' && $character !== "\t") { + $fieldState = self::FIELD_UNENCLOSED; + } } } - return $isInsideEnclosure; + return $fieldState; } /** @param iterable> $data */ diff --git a/tests/CSVArrayTest.php b/tests/CSVArrayTest.php index d7cad706..f5491756 100644 --- a/tests/CSVArrayTest.php +++ b/tests/CSVArrayTest.php @@ -203,6 +203,54 @@ public static function lineBreakVariants(): iterable ]; } + /** @return iterable>}> */ + public static function enclosuresOutsideEnclosedFields(): iterable + { + yield 'inch mark in unenclosed field' => [ + "size;name\r\n12\";Disk\r\n14;Other\r\n", + [ + 1 => ['size' => '12"', 'name' => 'Disk'], + 2 => ['size' => '14', 'name' => 'Other'], + ], + ]; + yield 'text after closing enclosure' => [ + "a;b\r\n\"x\"y\";z\r\n1;2\r\n", + [ + 1 => ['a' => 'xy"', 'b' => 'z'], + 2 => ['a' => '1', 'b' => '2'], + ], + ]; + yield 'escaped enclosure in unenclosed field' => [ + "a;b\r\nx\\\";z\r\n1;2\r\n", + [ + 1 => ['a' => 'x\\"', 'b' => 'z'], + 2 => ['a' => '1', 'b' => '2'], + ], + ]; + } + + /** + * @dataProvider enclosuresOutsideEnclosedFields + * + * @param array> $array + */ + #[DataProvider('enclosuresOutsideEnclosedFields')] + public function testToArrayTreatsEnclosuresOutsideEnclosedFieldsAsText(string $csv, array $array): void + { + self::assertSame($array, CSVArray::toArray($csv)); + } + + public function testToArrayWithEscapeEqualToEnclosure(): void + { + self::assertSame( + [ + 1 => ['a' => 'x"y', 'b' => 'z'], + 2 => ['a' => "m\r\nn", 'b' => '2'], + ], + CSVArray::toArray("a;b\r\n\"x\"\"y\";z\r\n\"m\r\nn\";2\r\n", ';', '"', '"') + ); + } + /** * @dataProvider lineBreakVariants * From 036d1fc163fdc15bbfc9d927bffc5c76de4664bf Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 4 Nov 2026 13:47:18 +0100 Subject: [PATCH 6/7] fix: scan CSVArray records in one pass over the input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting into lines first kept every captured line break in memory and lost the escape state at line breaks. Scan the input once and cut records at line breaks outside enclosed fields. Also open an enclosure before checking for a delimiter, and skip vertical tabs and form feeds before an enclosure, as str_getcsv does. 🤖 Generated with Claude Code --- src/CSVArray.php | 61 ++++++++++++++++-------------------------- tests/CSVArrayTest.php | 16 +++++++++++ 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/src/CSVArray.php b/src/CSVArray.php index 5151e42c..d0040cf0 100644 --- a/src/CSVArray.php +++ b/src/CSVArray.php @@ -49,65 +49,50 @@ public static function toArray(string $csv, string $delimiter = ';', string $enc return $result; } - /** @return array */ + /** + * Follows the quoting rules of str_getcsv: an enclosure only opens at the start of a field. + * + * @return array + */ private static function splitRecords(string $csv, string $delimiter, string $enclosure, string $escape): array { $records = []; - $record = ''; + $recordStart = 0; $fieldState = self::FIELD_START; - foreach (\Safe\preg_split("/(\r\n|\n|\r)/", $csv, -1, PREG_SPLIT_DELIM_CAPTURE) as $index => $part) { - assert(is_string($part), 'preg_split returns strings.'); - - $isLineBreak = $index % 2 === 1; - if ($isLineBreak && $fieldState !== self::FIELD_ENCLOSED) { - $records[] = $record; - $record = ''; - $fieldState = self::FIELD_START; - - continue; - } - - $record .= $part; - if (! $isLineBreak) { - $fieldState = self::fieldStateAfter($part, $fieldState, $delimiter, $enclosure, $escape); - } - } - - $records[] = $record; - - return $records; - } - - /** Follows the quoting rules of str_getcsv: an enclosure only opens at the start of a field. */ - private static function fieldStateAfter(string $text, int $fieldState, string $delimiter, string $enclosure, string $escape): int - { - $length = strlen($text); + $length = strlen($csv); for ($position = 0; $position < $length; ++$position) { - $character = $text[$position]; + $character = $csv[$position]; if ($fieldState === self::FIELD_ENCLOSED) { if ($character === $escape && $escape !== $enclosure) { ++$position; } elseif ($character === $enclosure) { - if (($text[$position + 1] ?? '') === $enclosure) { + if (($csv[$position + 1] ?? '') === $enclosure) { ++$position; } else { $fieldState = self::FIELD_AFTER_ENCLOSURE; } } + } elseif ($character === "\r" || $character === "\n") { + $records[] = substr($csv, $recordStart, $position - $recordStart); + if ($character === "\r" && ($csv[$position + 1] ?? '') === "\n") { + ++$position; + } + $recordStart = $position + 1; + $fieldState = self::FIELD_START; + } elseif ($fieldState === self::FIELD_START && $character === $enclosure) { + $fieldState = self::FIELD_ENCLOSED; } elseif ($character === $delimiter) { $fieldState = self::FIELD_START; - } elseif ($fieldState === self::FIELD_START) { - if ($character === $enclosure) { - $fieldState = self::FIELD_ENCLOSED; - } elseif ($character !== ' ' && $character !== "\t") { - $fieldState = self::FIELD_UNENCLOSED; - } + } elseif ($fieldState === self::FIELD_START && strpos(" \t\v\f", $character) === false) { + $fieldState = self::FIELD_UNENCLOSED; } } - return $fieldState; + $records[] = substr($csv, $recordStart); + + return $records; } /** @param iterable> $data */ diff --git a/tests/CSVArrayTest.php b/tests/CSVArrayTest.php index f5491756..9ac9a2d9 100644 --- a/tests/CSVArrayTest.php +++ b/tests/CSVArrayTest.php @@ -187,6 +187,12 @@ public static function lineBreakVariants(): iterable 2 => ['a' => '1', 'b' => '2'], ], ]; + yield 'vertical tab before enclosure' => [ + "a;b\n\v\"first\nsecond\";x\n", + [ + 1 => ['a' => "first\nsecond", 'b' => 'x'], + ], + ]; yield 'enclosed empty value in single column' => [ "name\r\n\"\"\r\nBob\r\n", [ @@ -240,6 +246,16 @@ public function testToArrayTreatsEnclosuresOutsideEnclosedFieldsAsText(string $c self::assertSame($array, CSVArray::toArray($csv)); } + public function testToArrayWithLineFeedAsEscape(): void + { + self::assertSame( + [ + 1 => ['a' => 'x', 'b' => "hello\n\"world\nend"], + ], + CSVArray::toArray("a;b\nx;\"hello\n\"world\nend\"\n", ';', '"', "\n") + ); + } + public function testToArrayWithEscapeEqualToEnclosure(): void { self::assertSame( From 50bb216536fea1a9a3a0cd8fbc0194c436d5200d Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 4 Nov 2026 14:00:30 +0100 Subject: [PATCH 7/7] fix: ignore Safe substr rule for PHP 7.4 in CSVArray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/CSVArray.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CSVArray.php b/src/CSVArray.php index d0040cf0..3c8478ef 100644 --- a/src/CSVArray.php +++ b/src/CSVArray.php @@ -75,7 +75,7 @@ private static function splitRecords(string $csv, string $delimiter, string $enc } } } elseif ($character === "\r" || $character === "\n") { - $records[] = substr($csv, $recordStart, $position - $recordStart); + $records[] = substr($csv, $recordStart, $position - $recordStart); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) if ($character === "\r" && ($csv[$position + 1] ?? '') === "\n") { ++$position; } @@ -90,7 +90,7 @@ private static function splitRecords(string $csv, string $delimiter, string $enc } } - $records[] = substr($csv, $recordStart); + $records[] = substr($csv, $recordStart); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) return $records; }