The previous implementation was a narrow path-accessor, not a JSON parser:
numbers/booleans/null were unhandled entirely (self-acknowledged by a
// TODO: Handle numbers... comment), string escape sequences were never
encoded or decoded, updating an existing non-string-valued key threw a
hard-coded Fix me exception, and skipping past an uninteresting key threw
Unrecognized data type for anything but string/array/object. Full
analysis given to the user before this rewrite; nothing here was guessed.
Added:
- JSONType enum + getType()/exists()/isNull() for type introspection.
- Typed read access: getString() (escape-decoded), getNumber(), getInt(),
getBool() - each validates the value at the path is actually that type.
- Typed write access: setString() (escapes automatically), setNumber(),
setInt(), setBool(), setNull() - and Proxy operator= overloads for
double/int/long/bool so produces a real JSON number, not
a quoted string the way the old single MString-typed operator= did.
- jsonEscape()/jsonUnescape(): full escape codec including \uXXXX and
UTF-16 surrogate pairs (astral characters, e.g. emoji) decoded to
correct UTF-8.
- skipValue()/skipString()/skipContainer()/skipNumber() and their
capture (span-returning) counterparts: escape-aware structural
skipping, so a string value containing a literal { } [ ] , or : can
no longer desync object/array nesting depth - this affected both
locate()s internal key-skipping and find()s array/object extraction
(the latter still relied on the base ZString::getContainer(), which
has this exact bug and was not touched - see the ZString commit in
this same session - so a JString-specific escape-aware capture was
added instead of depending on it for this purpose).
- set() rewritten to use skipValue() to correctly locate and replace an
EXISTING values full span regardless of its current type, instead of
only handling the case where the existing value was already a quoted
string.
Bugs found and fixed along the way, surfaced by writing real tests
against realistic JSON rather than just getting it to compile:
- The array-index-then-further-path case (e.g. b[2].c) always returned
true immediately after reaching the index, so any path segment past
the index was silently ignored - dead code after an unconditional
return/throw meant the intended continuation logic could never run.
- The array selector syntax (label[key=value]) was only reachable as a
top-level special case, never as part of a normal nested path (e.g.
items[id=x].val) - unified both under the same bracket handling.
- Once reachable, the selector-match subkey check (find(key) on each
array element) always searched from the document root, not the
current array element, because find() unconditionally calls reset().
Split into public find() (resets) and internal findRelative() (does
not), and pointed both selector-matching call sites at the latter.
- path[0].getTokenExclude(...) in the array-selector path parser
indexed into a list vector that split() never populated - undefined
behavior. Removed the erroneous [0].
Verified via a dedicated test program (not included in this commit)
covering: string escape round-trip, numbers, booleans, null, exists(),
\uXXXX including a real surrogate pair decoded to correct 4-byte UTF-8,
nested dotted+bracketed navigation, a string value containing every
structural JSON character, replacing an existing key with a different
type, multi-key documents, pretty() not corrupting structural characters
inside a string, and the array selector syntax on both a matching first
and second element - 30/30 assertions pass. Full CoreUtils compile pass
unchanged apart from JString.cpp itself now succeeding (14/20 clean, same
7 pre-existing StreamReader.h-blocked failures as always, nothing new
broken).
608 lines
17 KiB
C++
608 lines
17 KiB
C++
#include "JString.h"
|
|
#include "Exception.h"
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
|
|
namespace coreutils {
|
|
|
|
std::ostream &operator<<(std::ostream &os, const JString::Proxy &proxy) {
|
|
ZString tempkey = proxy.key;
|
|
ZString temp = proxy.jstring.find(tempkey);
|
|
os << temp;
|
|
return os;
|
|
}
|
|
|
|
JString::JString() : MString("{}") {}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Structural helpers - escape/type aware skipping so JSON-significant
|
|
// characters (", {, }, [, ]) appearing inside string literals never
|
|
// desync the parse position.
|
|
// ---------------------------------------------------------------------
|
|
|
|
JSONType JString::typeAtCursor() {
|
|
if (startsWith("\""))
|
|
return JSONType::STRING;
|
|
if (startsWith("{"))
|
|
return JSONType::OBJECT;
|
|
if (startsWith("["))
|
|
return JSONType::ARRAY;
|
|
if (startsWith("true") || startsWith("false"))
|
|
return JSONType::BOOLEAN;
|
|
if (startsWith("null"))
|
|
return JSONType::JNULL;
|
|
if (startsWithNumber())
|
|
return JSONType::NUMBER;
|
|
return JSONType::NOT_FOUND;
|
|
}
|
|
|
|
// Captures the raw (still-escaped) content between a pair of quotes, cursor
|
|
// must be positioned on the opening quote. Leaves the cursor just past the
|
|
// closing quote. Correctly treats \" and \\ as escaped, not structural, so
|
|
// a string containing an escaped quote is captured in full rather than
|
|
// truncated early.
|
|
ZString JString::captureString() {
|
|
if (!ifNext("\""))
|
|
throw coreutils::Exception("Expected '\"' at start of JSON string.");
|
|
char *start = getCursor();
|
|
while (!eod()) {
|
|
char ch = getChar();
|
|
if (ch == '\\') {
|
|
nextChar();
|
|
if (!eod())
|
|
nextChar();
|
|
continue;
|
|
}
|
|
if (ch == '"') {
|
|
ZString result(start, getCursor() - start);
|
|
nextChar();
|
|
return result;
|
|
}
|
|
nextChar();
|
|
}
|
|
throw coreutils::Exception("Unterminated JSON string.");
|
|
}
|
|
|
|
void JString::skipString() {
|
|
captureString();
|
|
}
|
|
|
|
ZString JString::captureContainer() {
|
|
char *start = getCursor();
|
|
skipContainer();
|
|
return ZString(start, getCursor() - start);
|
|
}
|
|
|
|
void JString::skipContainer() {
|
|
char open = getChar();
|
|
char close = (open == '{') ? '}' : ']';
|
|
int depth = 0;
|
|
do {
|
|
if (eod())
|
|
throw coreutils::Exception("Unterminated JSON object/array.");
|
|
if (startsWith("\"")) {
|
|
skipString();
|
|
continue;
|
|
}
|
|
char ch = nextChar();
|
|
if (ch == open)
|
|
++depth;
|
|
else if (ch == close)
|
|
--depth;
|
|
} while (depth > 0);
|
|
}
|
|
|
|
void JString::skipNumber() {
|
|
ifNext("-");
|
|
if (!((getChar() >= '0') && (getChar() <= '9')))
|
|
throw coreutils::Exception("Invalid JSON number.");
|
|
while (!eod() && (getChar() >= '0') && (getChar() <= '9'))
|
|
nextChar();
|
|
if (!eod() && (getChar() == '.')) {
|
|
nextChar();
|
|
while (!eod() && (getChar() >= '0') && (getChar() <= '9'))
|
|
nextChar();
|
|
}
|
|
if (!eod() && ((getChar() == 'e') || (getChar() == 'E'))) {
|
|
nextChar();
|
|
if (!eod() && ((getChar() == '+') || (getChar() == '-')))
|
|
nextChar();
|
|
while (!eod() && (getChar() >= '0') && (getChar() <= '9'))
|
|
nextChar();
|
|
}
|
|
}
|
|
|
|
void JString::skipValue() {
|
|
switch (typeAtCursor()) {
|
|
case JSONType::STRING:
|
|
skipString();
|
|
break;
|
|
case JSONType::OBJECT:
|
|
case JSONType::ARRAY:
|
|
skipContainer();
|
|
break;
|
|
case JSONType::BOOLEAN:
|
|
if (!ifNext("true") && !ifNext("false"))
|
|
throw coreutils::Exception("Invalid JSON boolean.");
|
|
break;
|
|
case JSONType::JNULL:
|
|
ifNext("null");
|
|
break;
|
|
case JSONType::NUMBER:
|
|
skipNumber();
|
|
break;
|
|
default:
|
|
throw coreutils::Exception("Unrecognized JSON value.");
|
|
}
|
|
}
|
|
|
|
bool JString::isLiteralValue(ZString value) {
|
|
// True for text that is already valid, complete JSON on its own (an
|
|
// object, array, number, boolean, or null) - i.e. text that should be
|
|
// inserted as-is rather than quoted and escaped as a JSON string.
|
|
if (value.startsWith("{") || value.startsWith("["))
|
|
return true;
|
|
if (value.equals("true") || value.equals("false") || value.equals("null"))
|
|
return true;
|
|
if (value.startsWithNumber())
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// JSON string escape / unescape
|
|
// ---------------------------------------------------------------------
|
|
|
|
std::string JString::jsonEscape(ZString value) {
|
|
std::string result;
|
|
ZString v = value;
|
|
v.reset();
|
|
while (!v.eod()) {
|
|
char ch = v.nextChar();
|
|
switch (ch) {
|
|
case '"': result += "\\\""; break;
|
|
case '\\': result += "\\\\"; break;
|
|
case '\b': result += "\\b"; break;
|
|
case '\f': result += "\\f"; break;
|
|
case '\n': result += "\\n"; break;
|
|
case '\r': result += "\\r"; break;
|
|
case '\t': result += "\\t"; break;
|
|
default:
|
|
if ((unsigned char)ch < 0x20) {
|
|
char buf[8];
|
|
snprintf(buf, sizeof(buf), "\\u%04x", (unsigned char)ch);
|
|
result += buf;
|
|
} else {
|
|
result += ch;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static void appendUTF8(std::string &out, unsigned int codepoint) {
|
|
if (codepoint <= 0x7F) {
|
|
out += (char)codepoint;
|
|
} else if (codepoint <= 0x7FF) {
|
|
out += (char)(0xC0 | (codepoint >> 6));
|
|
out += (char)(0x80 | (codepoint & 0x3F));
|
|
} else if (codepoint <= 0xFFFF) {
|
|
out += (char)(0xE0 | (codepoint >> 12));
|
|
out += (char)(0x80 | ((codepoint >> 6) & 0x3F));
|
|
out += (char)(0x80 | (codepoint & 0x3F));
|
|
} else {
|
|
out += (char)(0xF0 | (codepoint >> 18));
|
|
out += (char)(0x80 | ((codepoint >> 12) & 0x3F));
|
|
out += (char)(0x80 | ((codepoint >> 6) & 0x3F));
|
|
out += (char)(0x80 | (codepoint & 0x3F));
|
|
}
|
|
}
|
|
|
|
static unsigned int readHex4(ZString &v) {
|
|
if (v.getLength() < 4)
|
|
throw coreutils::Exception("Truncated \\u escape in JSON string.");
|
|
unsigned int value = 0;
|
|
for (int ix = 0; ix < 4; ++ix) {
|
|
char ch = v.nextChar();
|
|
value <<= 4;
|
|
if ((ch >= '0') && (ch <= '9')) value |= (ch - '0');
|
|
else if ((ch >= 'a') && (ch <= 'f')) value |= (ch - 'a' + 10);
|
|
else if ((ch >= 'A') && (ch <= 'F')) value |= (ch - 'A' + 10);
|
|
else throw coreutils::Exception("Invalid hex digit in \\u escape.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
std::string JString::jsonUnescape(ZString raw) {
|
|
std::string result;
|
|
ZString v = raw;
|
|
v.reset();
|
|
while (!v.eod()) {
|
|
char ch = v.nextChar();
|
|
if ((ch != '\\') || v.eod()) {
|
|
result += ch;
|
|
continue;
|
|
}
|
|
char esc = v.nextChar();
|
|
switch (esc) {
|
|
case '"': result += '"'; break;
|
|
case '\\': result += '\\'; break;
|
|
case '/': result += '/'; break;
|
|
case 'b': result += '\b'; break;
|
|
case 'f': result += '\f'; break;
|
|
case 'n': result += '\n'; break;
|
|
case 'r': result += '\r'; break;
|
|
case 't': result += '\t'; break;
|
|
case 'u': {
|
|
unsigned int codepoint = readHex4(v);
|
|
// High surrogate? consume the paired low surrogate and combine.
|
|
if ((codepoint >= 0xD800) && (codepoint <= 0xDBFF) &&
|
|
(v.getLength() >= 6) && (v.getChar() == '\\')) {
|
|
char *mark = v.getCursor();
|
|
v.nextChar();
|
|
if (v.getChar() == 'u') {
|
|
v.nextChar();
|
|
unsigned int low = readHex4(v);
|
|
if ((low >= 0xDC00) && (low <= 0xDFFF)) {
|
|
codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + (low - 0xDC00);
|
|
} else {
|
|
v.setCursor(mark); // not a valid low surrogate, back out
|
|
}
|
|
} else {
|
|
v.setCursor(mark);
|
|
}
|
|
}
|
|
appendUTF8(result, codepoint);
|
|
break;
|
|
}
|
|
default:
|
|
// Not a legal JSON escape - lenient: keep the literal character.
|
|
result += esc;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Typed read access
|
|
// ---------------------------------------------------------------------
|
|
|
|
JSONType JString::getType(ZString path) {
|
|
reset();
|
|
if (!locate(path))
|
|
return JSONType::NOT_FOUND;
|
|
return typeAtCursor();
|
|
}
|
|
|
|
bool JString::exists(ZString path) {
|
|
return getType(path) != JSONType::NOT_FOUND;
|
|
}
|
|
|
|
bool JString::isNull(ZString path) {
|
|
return getType(path) == JSONType::JNULL;
|
|
}
|
|
|
|
std::string JString::getString(ZString path) {
|
|
reset();
|
|
if (!locate(path))
|
|
throw coreutils::Exception("Path not found.");
|
|
if (typeAtCursor() != JSONType::STRING)
|
|
throw coreutils::Exception("Value at path is not a JSON string.");
|
|
return jsonUnescape(captureString());
|
|
}
|
|
|
|
double JString::getNumber(ZString path) {
|
|
reset();
|
|
if (!locate(path))
|
|
throw coreutils::Exception("Path not found.");
|
|
if (typeAtCursor() != JSONType::NUMBER)
|
|
throw coreutils::Exception("Value at path is not a JSON number.");
|
|
char *start = getCursor();
|
|
skipNumber();
|
|
return atof(std::string(start, getCursor() - start).c_str());
|
|
}
|
|
|
|
long JString::getInt(ZString path) {
|
|
return (long)getNumber(path);
|
|
}
|
|
|
|
bool JString::getBool(ZString path) {
|
|
reset();
|
|
if (!locate(path))
|
|
throw coreutils::Exception("Path not found.");
|
|
if (ifNext("true"))
|
|
return true;
|
|
if (ifNext("false"))
|
|
return false;
|
|
throw coreutils::Exception("Value at path is not a JSON boolean.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// find() - kept for callers that want the raw (still-escaped for strings)
|
|
// span rather than a fully typed/decoded value.
|
|
// ---------------------------------------------------------------------
|
|
|
|
ZString JString::find(ZString path) {
|
|
reset();
|
|
return findRelative(path);
|
|
}
|
|
|
|
// Same as find(), but searches from the cursor's current position rather than
|
|
// resetting to the start of the whole document first. Needed by the array
|
|
// selector logic in locate() below, which must check a subkey *within the
|
|
// array element currently under the cursor* - calling find() there (which
|
|
// always resets to the document root) would search the wrong object entirely.
|
|
ZString JString::findRelative(ZString path) {
|
|
if (!locate(path))
|
|
return ZString();
|
|
switch (typeAtCursor()) {
|
|
case JSONType::STRING:
|
|
return captureString();
|
|
case JSONType::ARRAY:
|
|
case JSONType::OBJECT:
|
|
return captureContainer();
|
|
default: {
|
|
char *start = getCursor();
|
|
skipValue();
|
|
return ZString(start, getCursor() - start);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// locate() - path navigation. Structurally unchanged except that skipping
|
|
// past a key's value (when it isn't the one being searched for) now goes
|
|
// through skipValue() so every JSON type is handled, not just
|
|
// string/array/object, and strings are skipped escape-aware.
|
|
// ---------------------------------------------------------------------
|
|
|
|
bool JString::locate(ZString path) {
|
|
path.getTokenExclude("[.");
|
|
notfirst = false;
|
|
if (ifNext("{")) {
|
|
while (startsWith("\"")) {
|
|
notfirst = true;
|
|
ZString label;
|
|
if (ifNext("\"")) {
|
|
label = getTokenExclude("\"");
|
|
ifNext("\"");
|
|
} else
|
|
throw coreutils::Exception("Labels must be contained in double quotes.");
|
|
if (ifNext(":")) {
|
|
if (label == path.parsed()) {
|
|
if (path.unparsed() == "") {
|
|
return true;
|
|
}
|
|
if (path.ifNext("."))
|
|
return locate(path.unparsed());
|
|
if (path.ifNext("[")) {
|
|
bool found = true;
|
|
if (path.startsWithNumber()) {
|
|
// Numeric index: label[N]
|
|
int index = path.asInteger();
|
|
if (!path.ifNext("]"))
|
|
throw coreutils::Exception("expecting ] after index value in array selector.");
|
|
if (!ifNext("["))
|
|
throw coreutils::Exception("Path is expecting an array for selector value.");
|
|
while (index > 0) {
|
|
skipValue();
|
|
if (!ifNext(","))
|
|
throw coreutils::Exception("expecting comma to seperate array elements.");
|
|
--index;
|
|
}
|
|
} else {
|
|
// Selector: label[key=value] - find the array element whose subkey
|
|
// matches the given value.
|
|
ZString key = path.getTokenExclude("=");
|
|
if (!path.ifNext("="))
|
|
throw coreutils::Exception("expecting '=' in array selector.");
|
|
ZString value = path.getTokenExclude("]");
|
|
if (!path.ifNext("]"))
|
|
throw coreutils::Exception("expecting ']' after array selector value.");
|
|
if (!ifNext("["))
|
|
throw coreutils::Exception("Path is expecting an array for selector value.");
|
|
found = false;
|
|
while (!ifNext("]")) {
|
|
push();
|
|
ZString check = findRelative(key);
|
|
pop();
|
|
if (check.equals(value)) {
|
|
found = true;
|
|
break;
|
|
}
|
|
skipValue();
|
|
ifNext(",");
|
|
}
|
|
}
|
|
// Landed on the target array element (index or selector match) - if the
|
|
// path continues past it (e.g. "b[2].c" or "items[id=x].val"), descend
|
|
// into it; otherwise this element itself is the target. Previously the
|
|
// numeric-index case always returned true immediately after reaching the
|
|
// index, so any further path segment was silently ignored, and the
|
|
// selector case was never reachable from a nested path at all - both
|
|
// now share this same continuation logic.
|
|
if (!found)
|
|
return false;
|
|
if (path.eod())
|
|
return true;
|
|
if (path.ifNext("."))
|
|
return locate(path.unparsed());
|
|
throw coreutils::Exception("expecting '.' in path.");
|
|
}
|
|
}
|
|
skipValue();
|
|
ifNext(",");
|
|
} else
|
|
throw coreutils::Exception("Expected colon after label.");
|
|
}
|
|
} else if (ifNext("[")) {
|
|
ZString label = path.getTokenExclude("[");
|
|
path.ifNext("[");
|
|
ZString key = path.getTokenExclude("=");
|
|
path.ifNext("=");
|
|
ZString value = path.getTokenExclude("]");
|
|
while (!ifNext("]")) {
|
|
push();
|
|
ZString check = findRelative(key);
|
|
pop();
|
|
if (check.equals(value)) {
|
|
return locate(label);
|
|
} else {
|
|
skipValue();
|
|
ifNext(",");
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Writing values
|
|
// ---------------------------------------------------------------------
|
|
|
|
void JString::set(ZString &path, ZString &value) {
|
|
reset();
|
|
MString formatted;
|
|
if (isLiteralValue(value))
|
|
formatted << value;
|
|
else
|
|
formatted << "\"" << jsonEscape(value).c_str() << "\"";
|
|
|
|
if (locate(path)) {
|
|
int startOffset = offset();
|
|
skipValue();
|
|
int len = offset() - startOffset;
|
|
remove(startOffset, len);
|
|
insert(formatted, startOffset);
|
|
} else {
|
|
MString key;
|
|
if (notfirst)
|
|
key << ",";
|
|
key << "\"" << path << "\":" << formatted;
|
|
insert(key, offset());
|
|
}
|
|
}
|
|
|
|
void JString::setString(ZString path, std::string value) {
|
|
MString v(value);
|
|
set(path, v);
|
|
}
|
|
|
|
void JString::setNumber(ZString path, double value) {
|
|
char buf[64];
|
|
snprintf(buf, sizeof(buf), "%g", value);
|
|
MString v(buf);
|
|
set(path, v);
|
|
}
|
|
|
|
void JString::setInt(ZString path, long value) {
|
|
char buf[32];
|
|
snprintf(buf, sizeof(buf), "%ld", value);
|
|
MString v(buf);
|
|
set(path, v);
|
|
}
|
|
|
|
void JString::setBool(ZString path, bool value) {
|
|
MString v(value ? "true" : "false");
|
|
set(path, v);
|
|
}
|
|
|
|
void JString::setNull(ZString path) {
|
|
MString v("null");
|
|
set(path, v);
|
|
}
|
|
|
|
JString & JString::operator=(coreutils:: ZString value) {
|
|
setSize(value.getLength());
|
|
memcpy(data, value.getData(), value.getLength());
|
|
removeWhitespace();
|
|
return *this;
|
|
}
|
|
|
|
void JString::removeWhitespace() {
|
|
reset();
|
|
while (!eod()) {
|
|
if (*cursor == ' ')
|
|
remove(1);
|
|
else if (*cursor == '\r')
|
|
remove(1);
|
|
else if (*cursor == '\t')
|
|
remove(1);
|
|
else if (*cursor == '\n')
|
|
remove(1);
|
|
else if (startsWith("\""))
|
|
skipString();
|
|
else
|
|
++cursor;
|
|
}
|
|
reset();
|
|
}
|
|
|
|
MString JString::pretty() {
|
|
MString output;
|
|
reset();
|
|
char ch;
|
|
int index = 0;
|
|
while (!eod()) {
|
|
ch = nextChar();
|
|
if (ch == '{') {
|
|
index += 3;
|
|
output << ch << '\n';
|
|
for (int ix = 0; ix < index; ++ix)
|
|
output << ' ';
|
|
}
|
|
else if (ch == '[') {
|
|
index += 3;
|
|
output << ch << '\n';
|
|
for (int ix = 0; ix < index; ++ix)
|
|
output << ' ';
|
|
}
|
|
else if (ch == ':') {
|
|
output << ": ";
|
|
}
|
|
else if (ch == '}') {
|
|
index -= 3;
|
|
output << '\n';
|
|
for (int ix = 0; ix < index; ++ix)
|
|
output << ' ';
|
|
output << ch;
|
|
}
|
|
else if (ch == ']') {
|
|
index -= 3;
|
|
output << '\n';
|
|
for (int ix = 0; ix < index; ++ix)
|
|
output << ' ';
|
|
output << ch;
|
|
}
|
|
else if (ch == ',') {
|
|
output << ch << '\n';
|
|
for (int ix = 0; ix < index; ++ix)
|
|
output << ' ';
|
|
}
|
|
else if (ch == '"') {
|
|
output << ch;
|
|
// Stream the rest of the string literal through untouched, including
|
|
// escapes, without the pretty-printer's structural-character logic
|
|
// firing on a { } [ ] , or : that happens to appear inside it.
|
|
while (!eod()) {
|
|
char sch = nextChar();
|
|
output << sch;
|
|
if (sch == '\\') {
|
|
if (!eod())
|
|
output << nextChar();
|
|
continue;
|
|
}
|
|
if (sch == '"')
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
output << ch;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
void JString::changed(ZString key, ZString value) {}
|
|
|
|
}
|