A reader for a small INI-style configuration format:

[server]
host = localhost
port = 8080

[logging]
level = info
file  = /var/log/app.log

Values are strings; sections group them; blank lines are allowed anywhere. The interesting cparse mechanic on display: scope tracking. Sections open with [name] and close either at the next […] or at end-of-input, and we want a section that opens and never closes to be reported as such — not silently defaulted.

The full program

#include <cparse/Parser.hh>

#include <cctype>
#include <fstream>
#include <iostream>
#include <map>
#include <optional>
#include <string>

using Config = std::map<std::string, std::map<std::string, std::string>>;
using fedem::parser::ParseError;

class KeyValueParser : public fedem::parser::Parser
{
public:
    std::string getGrammarName() const noexcept override { return "kv-config"; }

    Config const& config() const noexcept { return config_; }

protected:
    bool start() override
    {
        while (true)
        {
            skipCommentsBlock();
            if (!getCursor().isValid()) return true;

            char const c = getCursor().safePeek();
            if (c == '\032') return true;         // clean EOF

            if (c == '[')
            {
                if (!parseSection()) return false;
                continue;
            }

            if (std::isalpha(static_cast<unsigned char>(c)) || c == '_')
            {
                if (!parseAssignment(currentSection_)) return false;
                continue;
            }

            recordError(ParseError::Kind::Syntax,
                        std::string{"unexpected character '"} + c + "'");
            return false;
        }
    }

    // Override the base skipComments to eat '#' line comments.
    bool skipComments() override
    {
        if (getCursor().safePeek() != '#') return false;
        while (getCursor().isValid())
        {
            char const c = getCursor().safeGet();
            if (c == '\n' || c == '\032') return true;
        }
        return true;
    }

private:
    bool parseSection()
    {
        unsigned long const line = getCursor().getLineNumber();
        unsigned long const col  = getCursor().getColumnNumber();

        // If we already had a section open, close it before opening the new one.
        if (!currentSection_.empty())
            leaveScope();

        getCursor().get();                        // consume '['
        enterScope();                             // section is now open
        sectionOpenLine_ = line;
        sectionOpenCol_  = col;

        std::string name;
        while (true)
        {
            char const c = getCursor().safePeek();
            if (c == ']' || c == '\n' || c == '\032') break;
            name += getCursor().safeGet();
        }
        while (!name.empty() && std::isspace(
                   static_cast<unsigned char>(name.back())))
            name.pop_back();

        if (name.empty())
        {
            recordError(ParseError::Kind::Syntax, "empty section name",
                        line, col);
            return false;
        }

        if (!extractCharacter(']'))
        {
            recordError(ParseError::Kind::Syntax,
                        "expected ']' to close section '" + name + "'",
                        line, col);
            return false;
        }

        currentSection_ = name;
        config_[currentSection_];                 // register empty section
        return true;
    }

    bool parseAssignment(std::string const& section)
    {
        unsigned long const line = getCursor().getLineNumber();
        unsigned long const col  = getCursor().getColumnNumber();

        if (section.empty())
        {
            recordError(ParseError::Kind::Syntax,
                        "assignment outside of any [section]",
                        line, col);
            return false;
        }

        std::string key;
        while (true)
        {
            char const c = getCursor().safePeek();
            if (c == '=' || std::isspace(static_cast<unsigned char>(c))
                || c == '\032') break;
            key += getCursor().safeGet();
        }
        if (key.empty()) {
            recordError(ParseError::Kind::Syntax, "expected identifier", line, col);
            return false;
        }

        skipSpacesOnly();
        if (!extractCharacter('=')) {
            recordError(ParseError::Kind::Syntax, "expected '=' after key '" + key + "'");
            return false;
        }
        skipSpacesOnly();

        std::string value;
        while (true) {
            char const c = getCursor().safePeek();
            if (c == '\n' || c == '\032') break;
            value += getCursor().safeGet();
        }
        while (!value.empty() && std::isspace(
                   static_cast<unsigned char>(value.back())))
            value.pop_back();

        config_[section][key] = value;
        return true;
    }

    // Whitespace-not-including-newline — line boundaries matter here.
    void skipSpacesOnly()
    {
        while (true) {
            char const c = getCursor().safePeek();
            if (c != ' ' && c != '\t') return;
            getCursor().get();
        }
    }

    Config        config_;
    std::string   currentSection_;
    unsigned long sectionOpenLine_{0};
    unsigned long sectionOpenCol_{0};
};

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

    KeyValueParser p;
    bool const ok = p.parse(argv[1]);

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

    if (!ok) return 1;

    for (auto const& [section, entries] : p.config())
    {
        std::cout << '[' << section << "]\n";
        for (auto const& [k, v] : entries)
            std::cout << "  " << k << " = " << v << '\n';
    }
}

The grammar

config     := (section | assignment)*
section    := '[' name ']'
assignment := key '=' value

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. (# comments and blank lines are lexical noise skipCommentsBlock() eats between constructs — they aren't part of the grammar itself.)

config:
section:
assignment:

The interesting mechanic — scope tracking

enterScope()/leaveScope() exist so a file-reading grammar can tell "the input ended cleanly" apart from "a construct got cut off mid-way", by giving Parser::parse something to check after it catches the underlying end-of-input exception: a non-zero openScopeDepth() at that point means a scope opened but never closed, and parse() reports "unexpected end of input: N unterminated scope(s)" instead of treating the truncation as a clean finish.

This grammar calls enterScope()/leaveScope() around each [section], matching that convention — but every read here goes through safePeek()/safeGet() (see parseSection, parseAssignment, the start() loop), which never throws at end of input; it hands back an EOF sentinel character instead. parse()'s own EOF-catching path only fires when a throwing read (get()/peek()) runs out of input uncaught — which, as written, never happens here. So for this specific driver, openScopeDepth() tracks a count nothing ever reads back.

That does not mean a truncated file goes unreported — it is just caught a different way. Concretely, given:

[server]
host = localhost
[incomplete

parseSection reads the section name up to end-of-input (safePeek() returns the EOF sentinel exactly as it would ] or \n), then calls extractCharacter(']') to close it — which fails, since there is no ] there. That records "expected ']' to close section 'incomplete'" and returns false, which start() propagates; parse() adds its own generic "unexpected file parsing result" on top (any start() that returns false without a matching exit() gets that second, less specific diagnostic too — see Parser::parse). Two diagnostics, but the truncated section is still caught — just via the grammar's own explicit check, not the scope-count-at-EOF mechanism this pattern is normally paired with.

Given the same input with a trailing newline, or without one:

[server]
host = localhost

both parse cleanly (parse() returns true) — the last assignment is complete either way. An empty file, or one with only comments, also parses cleanly with an empty config_: nothing here ever fails on "there was nothing to parse", only on a construct that is visibly cut off partway through.

The custom skipComments

The base Parser::skipComments is a no-op. This grammar overrides it to eat # line comments — consume everything from # to end-of-line (or end-of-input). Because skipCommentsBlock calls skipComments() and skipWhiteSpaces() in a loop, # comments and blank lines mix freely without any per-call-site coordination.

The skipSpacesOnly helper

A separate helper for "consume spaces and tabs but not newlines". Line boundaries carry meaning here — key = value is a single line — so the base skipWhiteSpaces (which eats newlines too) is wrong for the intra-line spacing between key, = and value.

You do not need to override skipWhiteSpaces for this: the base version is called between top-level constructs by skipCommentsBlock in start(), where eating newlines is correct. The intra-line version is a private helper the grammar uses at specific spots.

Trying it

g++ -std=c++20 kv.cpp $(pkg-config --cflags --libs cparse) -o kv
cat > cfg.ini << 'END'
[server]
host = localhost
port = 8080

# a comment
[logging]
level = info
END
./kv cfg.ini

Expected output:

[logging]
  level = info
[server]
  host = localhost
  port = 8080

Truncated input demonstration (a real file, not a pipe — parse() 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 truncated.ini for readability:

printf '[server]\nhost = localhost\n[incomplete' > truncated.ini
./kv truncated.ini
# truncated.ini:2:17: expected ']' to close section 'incomplete'
# truncated.ini:3:11: unexpected file parsing result

What is deliberately absent

  • No type inference. Every value is a string. Callers convert as needed. A grammar that adds int, bool, float types would use ParseError::Kind::Type for bad conversions.
  • No section nesting. Sections do not compose here — one section closes at the next section header. A grammar with nested sections would call enterScope/leaveScope at each level of nesting.
  • No quoted strings. Values run from = to newline verbatim. Extending to quoted strings is a small change to the value-read loop.

See also