Streaming CSV Parser for Chunked Input

Incrementally parse CSV chunks with quoted fields, escaped quotes, embedded newlines, and CRLF endings using a dependency-free state machine.

JavascriptData Integrity

Code

snippet
export class CsvStreamParser {
  constructor({ delimiter = ",", onRow } = {}) {
    if (typeof delimiter !== "string" || delimiter.length !== 1) {
      throw new TypeError("delimiter must be a single character");
    }
    if (typeof onRow !== "function") {
      throw new TypeError("onRow must be a function");
    }
    this.delimiter = delimiter;
    this.onRow = onRow;
    this.field = "";
    this.row = [];
    this.rowStarted = false;
    this.inQuotes = false;
    this.pendingQuote = false;
    this.pendingCarriageReturn = false;
  }

  write(chunk) {
    const text = String(chunk);
    let startIndex = 0;
    if (this.pendingCarriageReturn) {
      this.pendingCarriageReturn = false;
      if (text.startsWith("\n")) startIndex = 1;
    }
    for (let index = startIndex; index < text.length; index += 1) {
      const char = text[index];
      if (this.inQuotes) {
        if (this.pendingQuote) {
          if (char === '"') {
            this.field += '"';
            this.pendingQuote = false;
            continue;
          }
          this.inQuotes = false;
          this.pendingQuote = false;
        } else if (char === '"') {
          this.pendingQuote = true;
          continue;
        } else {
          this.field += char;
          continue;
        }
      }
      if (char === '"') {
        if (this.field.length > 0) {
          throw new Error("quote must start a field");
        }
        this.inQuotes = true;
        this.rowStarted = true;
      } else if (char === this.delimiter) {
        this.row.push(this.field);
        this.field = "";
        this.rowStarted = true;
      } else if (char === "\n" || char === "\r") {
        if (char === "\r") {
          if (text[index + 1] === "\n") index += 1;
          else this.pendingCarriageReturn = true;
        }
        this.row.push(this.field);
        this.field = "";
        const row = this.row;
        this.row = [];
        this.rowStarted = false;
        this.onRow(row);
      } else {
        this.field += char;
        this.rowStarted = true;
      }
    }
    return this;
  }

  end() {
    if (this.inQuotes && !this.pendingQuote) {
      throw new Error("unterminated quoted field");
    }
    this.inQuotes = false;
    this.pendingQuote = false;
    this.pendingCarriageReturn = false;
    if (this.rowStarted || this.field.length > 0 || this.row.length > 0) {
      this.row.push(this.field);
      this.field = "";
      const row = this.row;
      this.row = [];
      this.rowStarted = false;
      this.onRow(row);
    }
  }
}

const rows = [];
const parser = new CsvStreamParser({ onRow: (row) => rows.push(row) });
parser.write('name,note\r\n"Ada","hello, ');
parser.write('world"\r\n"Grace","said ""hi"""');
parser.end();
console.log(JSON.stringify(rows));

const empty = [];
const quotedEmpty = new CsvStreamParser({ onRow: (row) => empty.push(row) });
quotedEmpty.write('""');
quotedEmpty.end();
console.log(JSON.stringify(empty));

const splitRows = [];
const splitCrlf = new CsvStreamParser({ onRow: (row) => splitRows.push(row) });
splitCrlf.write("a,b\r");
splitCrlf.write("\nc,d");
splitCrlf.end();
console.log(JSON.stringify(splitRows));

try {
  const broken = new CsvStreamParser({ onRow: () => {} });
  broken.write('"open');
  broken.end();
} catch (error) {
  console.log(error.message);
}