The smallest cparse grammar that still teaches something. Parses arithmetic expressions like 1 + 2 * 3 or (4 + 5) * -6, respecting the usual precedence of * / over + - and permitting parenthesised subexpressions and unary minus.

Full source below (about 130 lines including a driver). Each section is unpacked afterwards.

The full program

#include <cparse/Parser.hh>

#include <cctype>
#include <iostream>
#include <optional>
#include <sstream>
#include <variant>
#include <vector>

using fedem::parser::ParseError;

class Calculator : public fedem::parser::Parser
{
public:
    std::string getGrammarName() const noexcept override { return "toy-calc"; }

    // Public entry point: parse a string, return the computed value or
    // nullopt on error (with diagnostics in errors()).
    std::optional<double> evaluate(std::string const& source)
    {
        auto ss = std::make_shared<std::istringstream>(source);
        pushCursor("<expr>", ss);
        if (!start()) return std::nullopt;
        return result_;
    }

protected:
    bool start() override
    {
        skipWhiteSpaces();
        if (!parseExpression(result_)) return false;
        skipWhiteSpaces();
        if (getCursor().safePeek() != '\032')
        {
            recordError(ParseError::Kind::Syntax,
                        "trailing characters after expression");
            return false;
        }
        return true;
    }

private:
    // expression := term (('+' | '-') term)*
    bool parseExpression(double& out)
    {
        if (!parseTerm(out)) return false;
        skipWhiteSpaces();

        while (true)
        {
            char const op = getCursor().safePeek();
            if (op != '+' && op != '-') return true;
            getCursor().get();
            skipWhiteSpaces();

            double rhs;
            if (!parseTerm(rhs))
            {
                recordError(ParseError::Kind::Syntax,
                            std::string{"expected term after '"} + op + "'");
                return false;
            }
            out = (op == '+') ? out + rhs : out - rhs;
            skipWhiteSpaces();
        }
    }

    // term := factor (('*' | '/') factor)*
    bool parseTerm(double& out)
    {
        if (!parseFactor(out)) return false;
        skipWhiteSpaces();

        while (true)
        {
            char const op = getCursor().safePeek();
            if (op != '*' && op != '/') return true;
            getCursor().get();
            skipWhiteSpaces();

            double rhs;
            if (!parseFactor(rhs))
            {
                recordError(ParseError::Kind::Syntax,
                            std::string{"expected factor after '"} + op + "'");
                return false;
            }

            if (op == '/' && rhs == 0.0)
            {
                recordError(ParseError::Kind::Faulty, "division by zero");
                return false;
            }
            out = (op == '*') ? out * rhs : out / rhs;
            skipWhiteSpaces();
        }
    }

    // factor := number | '(' expression ')' | '-' factor
    bool parseFactor(double& out)
    {
        skipWhiteSpaces();
        char const c = getCursor().safePeek();

        if (c == '(')
        {
            getCursor().get();
            enterScope();
            if (!parseExpression(out)) return false;
            skipWhiteSpaces();
            if (!extractCharacter(')'))
            {
                recordError(ParseError::Kind::Syntax, "expected ')'");
                return false;
            }
            leaveScope();
            return true;
        }

        if (c == '-')
        {
            getCursor().get();
            if (!parseFactor(out)) return false;
            out = -out;
            return true;
        }

        if (std::isdigit(static_cast<unsigned char>(c)) || c == '.')
        {
            return parseNumber(out);
        }

        recordError(ParseError::Kind::Syntax, "expected number, '(' or '-'");
        return false;
    }

    bool parseNumber(double& out)
    {
        std::string s;
        while (true)
        {
            char const c = getCursor().safePeek();
            if (!std::isdigit(static_cast<unsigned char>(c)) && c != '.') break;
            s += getCursor().safeGet();
        }
        if (s.empty()) return false;

        try { out = std::stod(s); }
        catch (...) {
            recordError(ParseError::Kind::Type, "invalid number '" + s + "'");
            return false;
        }
        return true;
    }

    double result_ = 0.0;
};

int main()
{
    for (std::string line; std::getline(std::cin, line); )
    {
        if (line.empty()) continue;
        Calculator c;
        if (auto v = c.evaluate(line))
            std::cout << *v << '\n';
        else
            for (auto const& e : c.errors())
                std::cerr << e.filename << ':' << e.line << ':' << e.col
                          << ": " << e.message << '\n';
    }
}

The grammar

Standard textbook precedence expressed as three mutually recursive functions, each responsible for one precedence level:

expression := term (('+' | '-') term)*
term       := factor (('*' | '/') factor)*
factor     := number | '(' expression ')' | '-' factor

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:

expression:
term:
factor:
number:

parseExpression handles low-precedence + -. parseTerm handles higher-precedence * /. parseFactor handles the "atomic" units: numbers, parenthesised subexpressions, unary minus.

Recursion falls out naturally. Parenthesised subexpressions descend into parseExpression from inside parseFactor; unary minus descends into parseFactor from itself.

Reading the code

start() sets up and tears down the top-level parse. Skips leading whitespace, calls parseExpression, checks that nothing non-whitespace remains. The trailing-character check catches input like "1 + 2 garbage"parseExpression accepts 1 + 2 and returns, but start() sees garbage still on the cursor and reports the syntax error.

The check compares safePeek() against '\032', the EOF sentinel. This is idiomatic — safePeek never throws at end of input, it just returns the sentinel.

safePeek() decides what to do next; get() commits. The pattern in every parseXxx function is: peek to see whether the next character kicks off the production we handle, and only call get() after we have committed to consuming it. This lets a production fail without consuming input, so a caller can try alternatives.

Errors are recorded with Kind::Syntax for the recoverable cases and Kind::Faulty for the semantic ones. Division by zero is semantically wrong but syntactically fine — it deserves Faulty, not Syntax.

enterScope/leaveScope marks the parentheses — but nothing reads them back here. The pairing exists so Parser::parse can tell a truncated file apart from a clean end-of-input: it catches the underlying end-of-input exception, checks openScopeDepth(), and reports an unterminated scope instead of a clean finish if a scope was still open. Two things keep that from applying to (1 + 2, a missing close paren: evaluate() calls start() directly, never parse(), so nothing ever consults the depth; and even where this codebase's other examples do call parse(), their reads all use safePeek()/safeGet(), which hand back an EOF sentinel character instead of throwing — so the exception parse() is waiting to catch never actually happens. A missing close paren still gets a clear diagnostic here regardless: parseFactor's own extractCharacter(')') check fails and records "expected ')'" at the position it expected one — see it for yourself in "Trying it" below.

pushCursor reads from memory. evaluate wraps the input string in an istringstream and pushes a cursor around it. The grammar helpers work identically whether the source is a file or a string; the only difference is where the cursor was constructed.

Trying it

Compile and run:

g++ -std=c++20 toy_calc.cpp $(pkg-config --cflags --libs cparse) -o calc
echo "1 + 2 * 3"  | ./calc      # → 7
echo "(4+5)*-6"   | ./calc      # → -54
echo "1 + "        | ./calc     # → error: expected number, '(' or '-'
                                 #          (then) error: expected term after '+'
echo "10/0"       | ./calc      # → error: division by zero
echo "(1 + 2"     | ./calc      # → error: expected ')'

What is deliberately absent

  • No look-up table for operators. Two operator classes, three functions — the direct code is clearer than a table.
  • No AST. parseExpression returns the value by out-parameter. A real calculator would build an AST first and evaluate separately; the point here is to keep the grammar short.
  • No comment or whitespace override. The base skipWhiteSpaces — any run of std::isspace — is exactly right for arithmetic expressions. No comments in this language.

See also

  • Getting Started — the introductory walkthrough with an even simpler grammar (comma-separated integers).
  • Grammar Patterns — the recurring patterns this example uses.
  • Key/value config — the next example, which introduces scope tracking on a file-reading grammar.
  • CSV-lite — an iterator-style variant.