Capstone 1: writing JSON

Writing JSON looks like printing a tree, and one detail decides whether the output is valid: a string's contents have to be escaped. A quote inside a string ends it, and a newline inside a string is not allowed at all.

The writer of lesson 139 already printed compact JSON for data without special characters. This lesson adds the escape table and the indentation that make the output a document a person can read and a parser can read back.

The indent also shows the depth of a document in a diff: a nested array is one more level of spaces, and a reviewer sees the structure before reading the keys.

The writer is also where a number's format is decided: an integer and a double look the same in JSON, so the project stores every number as a double and prints it without a trailing zero.

The escape table

Six characters cannot appear raw inside a JSON string: the quote, the backslash and the control characters below 0x20. The escape function turns each into a backslash sequence, and the newline and tab get their short forms.

The program escapes a string with a quote, a backslash and a newline and prints the result. The output shows the quote and the backslash as two characters each and the newline as \n.

The other control characters take the \u00XX form, which is four hex digits. The table is small and it is the same in both directions, which is why the tests of the next lesson feed a round trip through it.

#include <iostream> #include <string> std::string escape(const std::string& text) { std::string result; for (char current : text) { switch (current) { case '"': result += '\\'; result += '"'; break; case '\\': result += "\\\\"; break; case '\n': result += "\\n"; break; case '\t': result += "\\t"; break; default: result += current; break; } } return result; } int main() { std::string text = "he said \"hi\"\n"; std::cout << escape(text) << '\n'; }

g++ -std=c++23 -Wall -Wextra escape.cpp -o json_escape
./json_escape

he said \"hi\"\n

Indentation and the recursive writer

The writer takes an indent width and a depth, so each level adds the same number of spaces. Arrays and objects print one child per line, and the closing bracket returns to the parent's indentation.

The program writes an object with an array inside and prints it over several lines. The 2 of the indent produces two spaces per level, and the output is the document a human wants to read.

The recursion is the same as the compact writer: one case per alternative, one call per child. The only state that travels down is the depth, and the only thing that comes back is the text.

#include <format> #include <iostream> #include <map> #include <string> #include <variant> #include <vector> struct value { std::variant<std::nullptr_t, bool, double, std::string, std::vector<value>, std::map<std::string, value>> data; }; std::string escape(const std::string& text) { std::string result; for (char current : text) { switch (current) { case '"': result += '\\'; result += '"'; break; case '\\': result += "\\\\"; break; case '\n': result += "\\n"; break; case '\t': result += "\\t"; break; default: result += current; break; } } return result; } std::string to_json(const value& item, int indent, int depth = 0) { const std::string pad(static_cast<std::size_t>(indent * depth), ' '); const std::string inner(static_cast<std::size_t>(indent * (depth + 1)), ' '); const char quote = '"'; switch (item.data.index()) { case 0: return "null"; case 1: return std::get<bool>(item.data) ? "true" : "false"; case 2: return std::format("{}", std::get<double>(item.data)); case 3: return quote + escape(std::get<std::string>(item.data)) + quote; case 4: { const auto& children = std::get<std::vector<value>>(item.data); std::string result = "["; for (std::size_t i = 0; i < children.size(); ++i) { if (i > 0) { result += ','; } if (indent > 0) { result += '\n' + inner; } result += to_json(children[i], indent, depth + 1); } if (indent > 0 && !children.empty()) { result += '\n' + pad; } return result + ']'; } default: { const auto& members = std::get<std::map<std::string, value>>(item.data); std::string result = "{"; std::size_t written = 0; for (const auto& [key, child] : members) { if (written++ > 0) { result += ','; } if (indent > 0) { result += '\n' + inner; } result += quote + escape(key); result += (indent > 0 ? ": " : ":"); result += to_json(child, indent, depth + 1); } if (indent > 0 && !members.empty()) { result += '\n' + pad; } return result + '}'; } } } int main() { std::map<std::string, value> members; members["name"] = value{std::string{"ada"}}; members["tags"] = value{std::vector<value>{value{std::string{"math"}}, value{std::string{"code"}}}}; value document{members}; std::cout << to_json(document, 2) << '\n'; }

g++ -std=c++23 -Wall -Wextra indent.cpp -o json_indent
./json_indent

{
"name": "ada",
"tags": [
"math",
"code"
]
}

A round trip through the writer

The test of a writer is a reader, and the test of the pair is a round trip: build a value, write it, parse it back and compare. With the writer's output as text, the check is one string comparison.

The program writes a small document with the indent of zero, which is the compact form, and compares it with the expected text. The print is round trip ok because the two strings are equal.

The compact form is what the tests use, because one line is easier to compare than a layout. The indented form is for people, and both come from the same function with a different indent width.

#include <format> #include <iostream> #include <map> #include <string> #include <variant> #include <vector> struct value { std::variant<std::nullptr_t, bool, double, std::string, std::vector<value>, std::map<std::string, value>> data; }; std::string escape(const std::string& text) { std::string result; for (char current : text) { switch (current) { case '"': result += '\\'; result += '"'; break; case '\\': result += "\\\\"; break; case '\n': result += "\\n"; break; case '\t': result += "\\t"; break; default: result += current; break; } } return result; } std::string to_json(const value& item, int indent, int depth = 0) { const std::string pad(static_cast<std::size_t>(indent * depth), ' '); const std::string inner(static_cast<std::size_t>(indent * (depth + 1)), ' '); const char quote = '"'; switch (item.data.index()) { case 0: return "null"; case 1: return std::get<bool>(item.data) ? "true" : "false"; case 2: return std::format("{}", std::get<double>(item.data)); case 3: return quote + escape(std::get<std::string>(item.data)) + quote; case 4: { const auto& children = std::get<std::vector<value>>(item.data); std::string result = "["; for (std::size_t i = 0; i < children.size(); ++i) { if (i > 0) { result += ','; } if (indent > 0) { result += '\n' + inner; } result += to_json(children[i], indent, depth + 1); } if (indent > 0 && !children.empty()) { result += '\n' + pad; } return result + ']'; } default: { const auto& members = std::get<std::map<std::string, value>>(item.data); std::string result = "{"; std::size_t written = 0; for (const auto& [key, child] : members) { if (written++ > 0) { result += ','; } if (indent > 0) { result += '\n' + inner; } result += quote + escape(key); result += (indent > 0 ? ": " : ":"); result += to_json(child, indent, depth + 1); } if (indent > 0 && !members.empty()) { result += '\n' + pad; } return result + '}'; } } } int main() { std::map<std::string, value> members; members["a"] = value{1.0}; members["b"] = value{true}; value document{members}; std::string written = to_json(document, 0); std::string expected = R"({"a":1,"b":true})"; std::cout << written << '\n'; std::cout << (written == expected ? "round trip ok" : "round trip failed") << '\n'; }

g++ -std=c++23 -Wall -Wextra roundtrip.cpp -o json_roundtrip
./json_roundtrip

{"a":1,"b":true}
round trip ok

The escape that was forgotten

A writer without the escape table produces text that looks right and does not parse. The quote inside the string ends it early, and the rest of the document is read as trailing garbage.

The program writes a string containing a quote without escaping it and then counts the quotes: the count is odd, so the document cannot be balanced. The print is the broken text and the word invalid.

The check in this program is a stand-in for the parser of lesson 138, which would report the trailing text. A round-trip test catches the class of bug in one line, which is why the capstone tests every case through it.

#include <iostream> #include <string> std::string to_json(const std::string& text) { const char quote = '"'; std::string result = "{"; result += quote; result += "s"; result += quote; result += ":"; result += quote; result += text; result += quote; result += "}"; return result; } bool balanced(const std::string& text) { int quotes = 0; for (char current : text) { if (current == '"') { ++quotes; } } return quotes % 2 == 0; } int main() { std::string text = "a"; text += '"'; text += "b"; std::string document = to_json(text); std::cout << document << '\n'; std::cout << (balanced(document) ? "valid" : "invalid") << '\n'; }

g++ -std=c++23 -Wall -Wextra unescaped.cpp -o json_unescaped
./json_unescaped

{"s":"a"b"}
invalid

Exercises

  1. Add the `\u00XX` form for the control characters and escape a string with a byte below 0x20.
  2. Write an empty object and an empty array with the indented writer and check the two-character output.
  3. Parse the writer's output with the parser of lesson 138 and print the depth of the result.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Which characters must be escaped inside a JSON string?

    Answer

    The quote, the backslash and the control characters

    The quote and the backslash are syntax, and the control characters are not allowed raw.

  2. What does the escape function do with a newline?

    Answer

    It writes the two characters backslash and n

    The short form is the readable one, and a parser turns it back into the newline.

  3. Why does the writer take a depth argument?

    Answer

    To know how many spaces each level needs

    The depth is what makes every level indent by the same width.

  4. What does this program print? #include <iostream> #include <string> std::string escape(const std::string& text) { std::string out; for (char c : text) { if (c == '"') { out += '\\'; out += '"'; } else if (c == '\\') { out += "\\\\"; } else if (c == '\n') { out += "\\n"; } else out += c; } return out; } int main() { std::cout << "he said " << escape("\"hi\"\n"); }

    Answer

    he said \"hi\"\n

    The quote and backslash are escaped, and the newline becomes the two characters backslash and n.

  5. What does the indented writer print for the document?

    Answer

    An object with one pair per line and a nested array

    The indent of two puts each member and each array element on its own line.

  6. What does this program print? #include <iostream> #include <map> #include <string> int main() { std::map<std::string, std::string> members{{"a", "1"}, {"b", "true"}}; std::string out = "{"; for (const auto& [key, value] : members) { if (out.size() > 1) out += ','; out += "\"" + key + "\":" + value; } out += '}'; std::cout << out << '\n' << (out == "{\"a\":1,\"b\":true}" ? "round trip ok" : "round trip failed"); }

    Answer

    {"a":1,"b":true} / round trip ok

    The map writes the two pairs in key order, and the result is exactly the expected text.

  7. Why does the round-trip test use an indent of zero?

    Answer

    Because one line is easier to compare with the expected text

    The compact form has no whitespace to disagree about, so the comparison is exact.

  8. What does the unescaped writer print?

    Answer

    {"s":"a"b"} and then invalid

    The quote in the data ends the string, so the quote count of the document is odd.

  9. What makes the unescaped document invalid?

    Answer

    A quote inside the string ends it early

    The rest of the value is then read as trailing text after the string.

  10. Which test catches a missing escape fastest?

    Answer

    A round trip through the writer and the parser

    The parser rejects the broken document, and the round trip reports it as one failure.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev