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.)



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,floattypes would useParseError::Kind::Typefor 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/leaveScopeat 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
- Toy calculator — arithmetic grammar with precedence.
- CSV-lite — iterator-style variant.
- Grammar Patterns › Nested scopes — the full pattern this example illustrates.
- Reference › Parser::enterScope — why the pattern deliberately avoids RAII.

