parseCsv function
Parses a multi-line CSV input, collecting per-row errors instead of
throwing on the first bad row — the shape a user-facing import needs so it
can surface every problem at once.
Rows are split on \n (a trailing \r is stripped, so CRLF files work) and
each non-blank line is parsed with parseCsvLine. Blank lines are skipped
silently (not an error). Two structural problems send a row to
CsvParseResult.errors rather than CsvParseResult.rows:
- Unterminated quote — an odd number of
"characters on the line (a valid line always has an even count: wrapping quotes and doubled escapes both come in pairs). This parser is line-oriented and does not support a quoted field that spans physical newlines. - Column-count mismatch — when an expected column count is known, a row
with a different field count is rejected. The expected count is
expectedColumnswhen given, otherwise the first non-blank row's count whenhasHeaderistrue. With neither, only quote validation runs.
When hasHeader is true the header row is still returned as the first
entry of CsvParseResult.rows (the caller knows it is the header).
Audited: 2026-06-12 11:26 EDT
Implementation
CsvParseResult parseCsv(
String input, {
String delimiter = ',',
bool hasHeader = false,
int? expectedColumns,
}) {
final List<List<String>> rows = <List<String>>[];
final List<CsvRowError> errors = <CsvRowError>[];
int? expected = expectedColumns;
final List<String> lines = input.split('\n');
for (int i = 0; i < lines.length; i++) {
// Strip a trailing CR so Windows CRLF input does not leave '\r' on the last
// field of every row.
final String line = lines[i].endsWith('\r')
? lines[i].substring(0, lines[i].length - 1)
: lines[i];
// Skip blank lines silently — they are padding, not malformed data.
if (line.isEmpty) {
continue;
}
// An odd quote count means a quoted field was never closed on this line.
if ('"'.allMatches(line).length.isOdd) {
errors.add(CsvRowError(i + 1, line, 'unterminated quote'));
continue;
}
final List<String> fields = parseCsvLine(line, delimiter: delimiter);
// The first surviving row sets the expected width when a header is declared
// and no explicit count was given.
expected ??= hasHeader ? fields.length : null;
if (expected != null && fields.length != expected) {
errors.add(
CsvRowError(
i + 1,
line,
'expected $expected columns, found ${fields.length}',
),
);
continue;
}
rows.add(fields);
}
return CsvParseResult(rows, errors);
}