parseCsvRecords function

List<CsvRecord> parseCsvRecords(
  1. String input
)

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;
}