parseCsvRecords function
parseCsv, keeping each record's line number.
Implementation
List<CsvRecord> parseCsvRecords(String input) {
final records = <CsvRecord>[];
var record = <String>[];
final field = StringBuffer();
var inQuotes = false;
var quotedField = false;
var line = 1;
var recordLine = 1;
var quoteOpenedOn = 1;
var anyContent = false;
void endField() {
record.add(quotedField ? field.toString() : field.toString().trim());
field.clear();
quotedField = false;
}
void endRecord() {
endField();
// A trailing newline produces one empty field, which is not a row.
if (record.length == 1 && record.first.isEmpty) {
record = <String>[];
recordLine = line + 1;
return;
}
records.add(CsvRecord(recordLine, record));
record = <String>[];
recordLine = line + 1;
}
for (var i = 0; i < input.length; i++) {
final char = input[i];
if (inQuotes) {
if (char == '"') {
if (i + 1 < input.length && input[i + 1] == '"') {
field.write('"');
i++;
} else {
inQuotes = false;
}
} else {
if (char == '\n') {
line++;
}
field.write(char);
}
continue;
}
switch (char) {
case '"':
if (quotedField) {
// A quoted value has already closed on this field, so this is text
// after it: `"abc"def`. Silently concatenating would read a mangled
// value as a good one, which is the whole thing this parser refuses
// to do.
throw FormatException(
'line $line: text after a closing quote — a value that contains a '
'quote has to double it ("") rather than close and reopen',
);
}
if (field.toString().trim().isNotEmpty) {
throw FormatException(
'line $line: a quote opens in the middle of a field — a value '
'containing a comma or a quote has to be quoted from its first '
'character',
);
}
field.clear();
inQuotes = true;
quotedField = true;
quoteOpenedOn = line;
anyContent = true;
case ',':
endField();
anyContent = true;
case '\r':
// Part of a CRLF, and the \n that follows ends the record.
//
// A *lone* carriage return is refused rather than swallowed. Swallowing
// it fuses two rows into one — `a,b\rc,d` becomes `[a, bc, d]`, with
// `b` and `c` joined — which is this parser reading a mangled value as
// a good one, the single thing it refuses to do everywhere else. Play
// does not emit classic-Mac line endings, so this costs nothing and
// closes the last path where the parser contradicts itself.
if (i + 1 >= input.length || input[i + 1] != '\n') {
throw FormatException(
'line $line: a carriage return not followed by a newline — a '
'classic-Mac line ending would silently join two rows',
);
}
case '\n':
endRecord();
line++;
default:
if (quotedField) {
// Whitespace between a closing quote and the delimiter is dropped
// rather than appended. Appending it is worse than it sounds: `"…" ,`
// would make an answer requirement of `REQUIRED ` — a value that no
// longer matches, silently, so a required question would read as
// optional. Anything that is *not* whitespace is text after a closing
// quote and is refused above.
if (char.trim().isEmpty) {
break;
}
throw FormatException(
'line $line: text after a closing quote — a value that contains a '
'quote has to double it ("") rather than close and reopen',
);
}
field.write(char);
anyContent = true;
}
}
if (inQuotes) {
throw FormatException(
'the file ends inside a quoted value that opened on line $quoteOpenedOn '
'— a missing closing quote swallows every row after it',
);
}
if (field.isNotEmpty || record.isNotEmpty) {
endRecord();
}
if (!anyContent) {
return const [];
}
return records;
}