Skip to Content
Course content

176: Building a Simple JSON Parser

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've always found that the best way to actually understand how a parser works is to try and build one the "wrong" way first. We're going to build a simple JSON parser. Now, you could spend weeks writing a perfect, spec-compliant beast, but for today, let's just try to get a small configuration string into a C++ object.

Let's start with this string: {"name": "Kaelen", "level": 42, "stats": {"str": 10, "dex": 15}}. It looks simple enough, right? Just some keys and values.

The "Split" Trap

My first instinct, if I'm being honest and lazy, is to just split the string by commas. I can find the colon, take the left side as the key and the right side as the value. Let's see what happens when I try that with our example.

// Imagine a loop splitting by ','
// First chunk: {"name": "Kaelen"
// Second chunk: "level": 42
// Third chunk: "stats": {"str": 10
// Fourth chunk: "dex": 15}}

Yeah, that's a disaster. The comma inside the stats object broke my logic. This is the classic "regular expression/splitting" trap. JSON isn't a flat list; it's a tree. If we have nested structures, we can't treat the input as a linear sequence of tokens separated by a single character. We need to track state.

Defining the Value Container

Before I can parse, I need somewhere to put the data. Since a JSON value can be a string, a number, or another object, I need a type that can be "anything." In modern C++, std::variant is our best friend here. I'll define a Value type that can hold a string, an integer, or a map of other Value objects.

#include <iostream>
#include <string>
#include <map>
#include <variant>
#include <vector>

struct Value; 
using Object = std::map<std::string, Value>;

struct Value {
    std::variant<std::string, int, Object> data;
};

I'm using a std::map for the Object because JSON keys are unique and we usually want to look them up by name. Now I have a place to store the result; now I just have to get the data in there without losing my mind.

Walking the String

Instead of splitting, I'll use a pointer (or an index) to walk through the string one character at a time. I'll create a Parser class that keeps track of the current position. Let's try to handle a simple string first. When I see a quote ", I know everything until the next quote is the actual text.

std::string parseString() {
    consumeWhitespace();
    if (current == '"') {
        current++; // skip opening quote
        std::string result;
        while (current != '"') {
            result += input[current++];
        }
        current++; // skip closing quote
        return result;
    }
    return "";
}

That works for the keys and the names. But what happens when I hit the curly brace {? That's the magic moment. That's where the parser needs to say, "Oh, I'm starting a new object, and I'll keep parsing until I find the matching closing brace."

The Recursive Leap

This is where recursive descent comes in. If the parseValue function sees a {, it doesn't just return a string; it calls parseObject. And parseObject, while looking for values, calls parseValue again.

I tried writing this linearly at first, but it became a nightmare of if/else blocks. The "aha!" moment was realizing that the structure of the code should mirror the structure of the JSON. If JSON can be nested, the code must be recursive.

Value parseValue() {
    consumeWhitespace();
    if (input[current] == '{') {
        return parseObject();
    } else if (input[current] == '"') {
        return Value{ parseString() };
    } else {
        return Value{ parseNumber() };
    }
}

Object parseObject() {
    Object obj;
    current++; // skip '{'
    while (input[current] != '}') {
        std::string key = parseString();
        consumeWhitespace();
        current++; // skip ':'
        obj[key] = parseValue(); // Here is the recursion!
        consumeWhitespace();
        if (input[current] == ',') current++; 
    }
    current++; // skip '}'
    return obj;
}

Now, if I run this against {"stats": {"str": 10}}, the flow is: parseValue $\rightarrow$ parseObject $\rightarrow$ parseString("stats") $\rightarrow$ parseValue $\rightarrow$ parseObject... and so on. It naturally handles any depth of nesting because the call stack keeps track of where we are in the hierarchy.

Dealing with the Messy Bits

I noticed immediately that my first version crashed on {"name" : "Kaelen"} because of the space before the colon. Real-world data is messy. I had to add a consumeWhitespace() helper that just eats up spaces, tabs, and newlines before every significant character check. It's a tedious bit of code, but without it, the parser is too fragile to be useful.

We've built a skeletal parser. It doesn't handle arrays or floating-point numbers yet, but the core logic—the recursive descent—is exactly how the heavy-hitters like RapidJSON or nlohmann/json operate under the hood.




📋 Practical Task

Extend the JSON Value System to Support Booleans

Currently, our Value struct only supports strings, integers, and objects. Your task is to extend the parser to handle boolean values (true and false).

  • Modify the Value struct's std::variant to include bool.
  • Implement a parseBoolean() method that checks if the current characters are "true" or "false".
  • Update the parseValue() logic to detect when a boolean is starting (usually by checking if the current character is 't' or 'f') and call parseBoolean() accordingly.
  • Test your parser with the following string: {"name": "Kaelen", "is_active": true, "stats": {"is_boss": false}}.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.