A reader for RFC-4180-style CSV — comma-separated fields, optional double-quoted fields that can contain commas and escaped double quotes ("" inside a quoted field means a literal "). Enough of CSV to be useful; not enough to be exhaustive (no BOM handling, no configurable delimiter).

Interesting cparse mechanic on display: this grammar wraps its consumption in a small iterator-style helper that yields one CSV row at a time. The reader is streaming — arbitrary-size input without loading the whole thing into memory — and each row's position (line, column of the row's first character) is retrievable if a downstream processor wants to report on it.

The full program

#include <cparse/Parser.hh>

#include <iostream>
#include <optional>
#include <string>
#include <vector>

using Row = std::vector<std::string>;
using fedem::parser::ParseError;

class CsvLite : public fedem::parser::Parser
{
public:
    std::string getGrammarName() const noexcept override { return "csv-lite"; }

    // Iterator-style: call nextRow() until it returns nullopt. The file
    // itself is opened via the inherited Parser::openStream() — see its
    // doc comment in Parser.hh for why parse() doesn't fit a grammar
    // driven from outside one row at a time.
    std::optional<Row> nextRow()
    {
        if (!getCursor().isValid()) return std::nullopt;
        if (getCursor().safePeek() == '\032') return std::nullopt;

        Row row;
        parseRow(row);
        return row;
    }

    // For callers who want the whole file at once.
    std::vector<Row> readAll()
    {
        std::vector<Row> rows;
        while (auto r = nextRow()) rows.push_back(std::move(*r));
        return rows;
    }

    // Position of the last successful row's start.
    unsigned long lastRowLine() const noexcept { return rowLine_; }
    unsigned long lastRowCol()  const noexcept { return rowCol_;  }

protected:
    bool start() override
    {
        // Never called: this grammar's entry point is openStream() +
        // nextRow(), not parse(). Present only because Parser declares it
        // pure.
        return true;
    }

private:
    void parseRow(Row& out)
    {
        rowLine_ = getCursor().getLineNumber() + 1;
        rowCol_  = getCursor().getColumnNumber() + 1;

        while (true)
        {
            out.push_back(parseField());

            char const c = getCursor().safePeek();
            if (c == ',')
            {
                getCursor().get();
                continue;
            }
            if (c == '\r')
            {
                getCursor().get();
                if (getCursor().safePeek() == '\n') getCursor().get();
                return;
            }
            if (c == '\n')
            {
                getCursor().get();
                return;
            }
            return;                    // EOF ends the last row
        }
    }

    std::string parseField()
    {
        if (getCursor().safePeek() == '"')
            return parseQuotedField();
        return parseUnquotedField();
    }

    std::string parseUnquotedField()
    {
        std::string s;
        while (true)
        {
            char const c = getCursor().safePeek();
            if (c == ',' || c == '\r' || c == '\n' || c == '\032') return s;
            s += getCursor().safeGet();
        }
    }

    std::string parseQuotedField()
    {
        unsigned long const openLine = getCursor().getLineNumber() + 1;
        unsigned long const openCol  = getCursor().getColumnNumber() + 1;

        getCursor().get();                      // consume opening "
        enterScope();                           // quoted-field scope

        std::string s;
        while (true)
        {
            char const c = getCursor().safePeek();
            if (c == '\032')
            {
                recordError(ParseError::Kind::Syntax,
                            "unterminated quoted field",
                            openLine, openCol);
                return s;
            }

            if (c == '"')
            {
                getCursor().get();
                // Doubled "" inside a quoted field → literal "
                if (getCursor().safePeek() == '"')
                {
                    getCursor().get();
                    s += '"';
                    continue;
                }
                leaveScope();
                return s;
            }

            s += getCursor().safeGet();
        }
    }

    unsigned long rowLine_ = 0;
    unsigned long rowCol_  = 0;
};

int main(int argc, char** argv)
{
    if (argc < 2)
    {
        std::cerr << "usage: csv <file.csv>\n";
        return 1;
    }

    CsvLite csv;
    if (!csv.openStream(argv[1]))
    {
        std::cerr << "csv: cannot open '" << argv[1] << "'\n";
        return 1;
    }

    size_t rowIndex = 0;
    while (auto row = csv.nextRow())
    {
        std::cout << "row " << ++rowIndex
                  << " (line " << csv.lastRowLine() << "): ";
        for (size_t i = 0; i < row->size(); ++i)
        {
            if (i) std::cout << " | ";
            std::cout << (*row)[i];
        }
        std::cout << '\n';
    }

    for (auto const& e : csv.errors())
        std::cerr << e.filename << ':' << e.line << ':' << e.col
                  << ": " << e.message << '\n';
}

The grammar

file          := row (line-terminator row)*
row           := field (',' field)*
field         := unquoted-field | quoted-field
quoted-field  := '"' (any char except '"' | '""')* '"'

Same grammar as railroad diagrams — a rounded box is a literal token, a square box is a reference to another rule, read left to right. (unquoted-field is just "any run of characters up to the next ,, line terminator, or end of input" — plain enough that it isn't worth its own diagram.)

file:
row:
field:
quoted-field:

The iterator wrinkle

The public interface is not "parse everything, then read the result". It is nextRow() — call until it returns nullopt.

Two consequences fall out of that choice:

  1. start() is a no-op — never actually called. The whole point of the grammar is consumed row by row from outside, not from a top-level parse loop.
  2. The caller opens the file through Parser::openStream, not parse(). Parser::parse is built around a single top-level start() driving the whole grammar, and it tears the cursor down the instant start() returns — right for a one-shot grammar, fatal here, since nextRow() needs the cursor to outlive that call. openStream() does only the setup half parse() does and stops there, leaving the cursor live for the caller to drive.

This is not the shape Parser was primarily designed for, but it composes cleanly: everything the iterator needs — cursor state, position tracking, error collection — is already there. We just do not use start()/parse() as the entry point.

If the input has trailing garbage after the last row, the iterator returns it as a row rather than reporting it — that is the pragmatic CSV behaviour (a stray line at end is a row of one field).

The quoted-field scope

Every quoted field opens a scope. The scope closes when the matching unescaped " is consumed. If EOF fires before the closing quote (unterminated field), the code inside parseQuotedField records the diagnostic with the opening quote's coordinates, not the current EOF position:

recordError(ParseError::Kind::Syntax,
            "unterminated quoted field",
            openLine, openCol);

This is exactly what Parser::recordError's explicit line/col parameters are for. Without them, the diagnostic would point at EOF, which is technically true but useless — the user wants to know which quote failed to close.

The scope tracking still counts (openScopeDepth() goes non-zero while a quoted field is open), but nothing here reads it back the way Parser::parse does at its own end-of-input — this grammar never calls parse() at all, only openStream() + nextRow(), so there is no automatic "unterminated scope" check to cooperate with. That is exactly why parseQuotedField records its own diagnostic explicitly instead of leaning on one: for this iterator-style grammar, the per-field message — pointing at the opening quote — is more useful than a generic end-of-input one would have been anyway.

Line/column reporting

The Cursor line and column are updated by get() / safeGet() calls on the character just consumed. So immediately after the opening " is consumed, getLineNumber() and getColumnNumber() report the quote's position — but our convention is to display positions one-based, and the cursor's line/column start at zero before the first read. We add 1 in the reporting path to convert to the display convention.

If your consumer prefers zero-based positions, drop the + 1.

Trying it

g++ -std=c++20 csv.cpp $(pkg-config --cflags --libs cparse) -o csv

cat > data.csv << 'END'
name,age,city
Alice,30,"Munich, Germany"
Bob,25,Istanbul
"Charlie ""C.""",45,"London, UK"
END

./csv data.csv

Expected output:

row 1 (line 1): name | age | city
row 2 (line 2): Alice | 30 | Munich, Germany
row 3 (line 3): Bob | 25 | Istanbul
row 4 (line 4): Charlie "C." | 45 | London, UK

Malformed input demonstration (a real file, not a pipe — openStream() resolves its argument with std::filesystem::canonical, which cannot resolve /dev/stdin piped from a shell). The path in the diagnostic below is that resolved absolute path, not the plain name given on the command line — shortened here to malformed.csv for readability:

printf 'name,city\n"Alice,Munich\n' > malformed.csv
./csv malformed.csv
# malformed.csv:2:11: unterminated quoted field

The diagnostic points at the opening quote of the malformed field — not at end-of-file.

What is deliberately absent

  • Configurable delimiter or quote character. Add them as constructor arguments in a real implementation.
  • Header row handling. The first row is returned like any other. A wrapper class would treat it as column names.
  • Chunked or memory-mapped input. The cursor already streams, so memory footprint is fine, but for gigabyte-scale files a mmap-based custom stream passed to pushCursor would be a small win.

See also