diff --git a/JString.cpp b/JString.cpp index de91bc8..3d33461 100644 --- a/JString.cpp +++ b/JString.cpp @@ -1,5 +1,7 @@ #include "JString.h" #include "Exception.h" +#include +#include namespace coreutils { @@ -9,161 +11,527 @@ namespace coreutils { os << temp; return os; } - + JString::JString() : MString("{}") {} - void JString::set(ZString &path, ZString &value) { - reset(); - if(locate(path)) { - push(); - if(ifNext("\"")) { - ZString old = getTokenExclude("\""); - pop(); - remove(offset(), old.getLength() + 2); - MString key; - if(value.startsWith("[")) - key << value; - else if(value.startsWith("{")) - key << value; - else - key << "\"" << value << "\""; - insert(key, offset()); - } else { - pop(); - throw coreutils::Exception("Fix me to accept integer type."); + // --------------------------------------------------------------------- + // 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; } - } else { -// std::cout << "[" << unparsed() << "] >" << value << "<" << std::endl; - MString key; - if(notfirst) - key << ","; - // TODO: The value added must be parsed down to remove all whitespace. - if(value.startsWith("[")) - key << "\"" << path << "\":" << value; - else if(value.startsWith("{")) - key << "\"" << path << "\":" << value; - else - key << "\"" << path << "\":" << "\"" << value << "\""; - insert(key, offset()); + 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(); - if(locate(path)) { - if(ifNext("\"")) - return getTokenExclude("\""); -// TODO: Handle numbers that are not in double quotes. - - else if(startsWith("[")) - return getContainer(); - - else if(startsWith("{")) - return getContainer(); - } - return ZString(); + 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) { -// std::cout << "path locate: " << path << std::endl; path.getTokenExclude("[."); notfirst = false; - if(ifNext("{")) { - while(startsWith("\"")) { + if (ifNext("{")) { + while (startsWith("\"")) { notfirst = true; - ZString label; - ZString value; - if(ifNext("\"")) { - label = getTokenExclude("\""); - ifNext("\""); - } else - throw coreutils::Exception("Labels must be contained in double quotes."); - if(ifNext(":")) { -// std::cout << "path: (" << path.parsed() << ") (" << label << ") " << (label == path.parsed()) << std::endl; - if(label == path.parsed()) { - if(path.unparsed() == "") { -// std::cout << "found cursor: " << unparsed() << std::endl; + 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(".")) + if (path.ifNext(".")) return locate(path.unparsed()); - if(path.ifNext("[")) { - int index = path.asInteger(); - if(!path.ifNext("]")) - throw coreutils::Exception("expecting ] after index value in array selector."); - if(ifNext("[")) { - while(index > 0) { - getContainer(); - if(!ifNext(",")) + 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; - } - return true; - } else - throw coreutils::Exception("Path is expecting an array for selector value."); - if(path.eod()) + } + } 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; - else if(path.ifNext(".")) + if (path.eod()) + return true; + if (path.ifNext(".")) return locate(path.unparsed()); throw coreutils::Exception("expecting '.' in path."); - } - } -// std::cout << "+++" << unparsed() << std::endl; - if(startsWith("\"")) { - getContainer(); - } else if(startsWith("[")) { - getContainer(); - } else if(startsWith("{")) { - getContainer(); - } else { - throw coreutils::Exception("Unrecognized data type."); - } - ifNext(","); - } else - throw coreutils::Exception("Expected colon after label."); - } - } else if(ifNext("[")) { - ZString label = path[0].getTokenExclude("["); - path.ifNext("["); - ZString key = path[0].getTokenExclude("="); - path.ifNext("="); - ZString value = path[0].getTokenExclude("]"); - while(!ifNext("]")) { - push(); - ZString check = find(key); - pop(); - if(check.equals(value)) { - return locate(label); - } else { - getContainer(); - ifNext(","); - } - } + } + } + 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(","); + } + } } -// std::cout << "not found cursor: " << unparsed() << std::endl; 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 == ' ') + while (!eod()) { + if (*cursor == ' ') remove(1); - else if(*cursor == '\r') + else if (*cursor == '\r') remove(1); - else if(*cursor == '\t') + else if (*cursor == '\t') remove(1); - else if(*cursor == '\n') + else if (*cursor == '\n') remove(1); - else if(startsWith("\"")) - getContainer(); + else if (startsWith("\"")) + skipString(); else ++cursor; } @@ -175,48 +543,65 @@ namespace coreutils { reset(); char ch; int index = 0; - while(!eod()) { + while (!eod()) { ch = nextChar(); - if(ch == '{') { + if (ch == '{') { index += 3; output << ch << '\n'; - for(int ix = 0; ix < index; ++ix) + for (int ix = 0; ix < index; ++ix) output << ' '; } - else if(ch == '[') { + else if (ch == '[') { index += 3; output << ch << '\n'; - for(int ix = 0; ix < index; ++ix) + for (int ix = 0; ix < index; ++ix) output << ' '; } - else if(ch == ':') { + else if (ch == ':') { output << ": "; - } - else if(ch == '}') { + } + else if (ch == '}') { index -= 3; output << '\n'; - for(int ix = 0; ix < index; ++ix) + for (int ix = 0; ix < index; ++ix) output << ' '; output << ch; - } - else if(ch == ']') { + } + else if (ch == ']') { index -= 3; output << '\n'; - for(int ix = 0; ix < index; ++ix) + for (int ix = 0; ix < index; ++ix) output << ' '; output << ch; - } - else if(ch == ',') { + } + else if (ch == ',') { output << ch << '\n'; - for(int ix = 0; ix < index; ++ix) + for (int ix = 0; ix < index; ++ix) output << ' '; } - else + 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; + return output; } void JString::changed(ZString key, ZString value) {} - + } diff --git a/JString.h b/JString.h index 14f59e7..e645d16 100644 --- a/JString.h +++ b/JString.h @@ -14,7 +14,7 @@ namespace coreutils { /// access the objects elements using a named path as the index to the items. /// /// JString default constructor will create an empty JString object pointing - /// to an empty string. + /// to an empty object ("{}"). /// /// Use a constructor or assignment operator to initialize a JString to an /// initial JSON string. @@ -22,32 +22,82 @@ namespace coreutils { /// Several constructors are available including a handle to a std::FILE /// object to initialize the JSON string. On destruction the file is saved back. /// - /// The [] operator can be used to access elements within the JSON string. - /// Use: - /// jstring["key"] = "value"; - /// std::cout << jstring["key"]; + /// The [] operator can be used to access elements within the JSON string. + /// Assigning a value dispatches on the C++ type of the right-hand side, so the + /// resulting JSON is correctly typed rather than always being a quoted string: /// + /// jstring["name"] = "value"; // JSON string, escaped correctly + /// jstring["count"] = 42; // JSON number (integer) + /// jstring["price"] = 19.95; // JSON number + /// jstring["active"] = true; // JSON boolean + /// jstring["notes"].setNull(); // JSON null + /// + /// std::cout << jstring["name"]; + /// + /// Typed read access is available directly on the path (no need to go through + /// operator[] first): + /// + /// std::string name = jstring.getString("name"); + /// double price = jstring.getNumber("price"); + /// bool active = jstring.getBool("active"); + /// bool missing = !jstring.exists("notfound"); + /// JSONType t = jstring.getType("count"); // JSONType::NUMBER + /// + + enum class JSONType { + STRING, NUMBER, BOOLEAN, JNULL, ARRAY, OBJECT, NOT_FOUND + }; class JString : virtual public MString { public: JString(); - + void set(ZString &path, ZString &value); /// /// Use the find method to look through an object for member specified by /// the key in parameter 1. The cursor must be pointing to the { character - /// that begins the object's list. + /// that begins the object's list. Returns the raw (still-escaped, for + /// strings) span of the value - use getString()/getNumber()/getBool()/ + /// isNull() for decoded, typed access instead. /// ZString find(ZString path); - + bool locate(ZString path); bool notfirst; ZString path; + /// + /// Type-aware access. All of these call locate() internally, so path uses + /// the same dotted/bracketed syntax as find()/set() (e.g. "a.b[2].c"). + /// + + JSONType getType(ZString path); + bool exists(ZString path); + bool isNull(ZString path); + + std::string getString(ZString path); ///< Escape-decoded string content. + double getNumber(ZString path); + long getInt(ZString path); + bool getBool(ZString path); + + void setString(ZString path, std::string value); ///< Escapes value automatically. + void setNumber(ZString path, double value); + void setInt(ZString path, long value); + void setBool(ZString path, bool value); + void setNull(ZString path); + + /// + /// JSON string escape/unescape helpers (exposed as static utilities since + /// they operate on plain text, not on this object's own JSON document). + /// + + static std::string jsonEscape(ZString value); + static std::string jsonUnescape(ZString raw); + class Proxy { public: @@ -64,15 +114,53 @@ namespace coreutils { } operator std::string () { - return jstring.find(key).str(); + return jstring.getString(key); } - Proxy& operator=(coreutils::MString value) { - jstring.set(key, value); + operator double () { + return jstring.getNumber(key); + } + + operator bool () { + return jstring.getBool(key); + } + + Proxy& operator=(coreutils::MString value) { + jstring.setString(key, value.str()); jstring.changed(key, value); return *this; } + Proxy& operator=(const char *value) { + jstring.setString(key, std::string(value)); + jstring.changed(key, ZString(value)); + return *this; + } + + Proxy& operator=(double value) { + jstring.setNumber(key, value); + return *this; + } + + Proxy& operator=(int value) { + jstring.setInt(key, value); + return *this; + } + + Proxy& operator=(long value) { + jstring.setInt(key, value); + return *this; + } + + Proxy& operator=(bool value) { + jstring.setBool(key, value); + return *this; + } + + void setNull() { + jstring.setNull(key); + } + private: JString &jstring; ZString key; @@ -81,16 +169,32 @@ namespace coreutils { Proxy operator[](coreutils::ZString key) { return Proxy(*this, key); } - + JString & operator=(coreutils:: ZString value); MString pretty(); virtual void changed(ZString key, ZString value); - + private: void removeWhitespace(); - + + /// + /// Structural helpers used internally by locate()/set() to correctly skip + /// past a value of any JSON type without being confused by JSON-structural + /// characters (quotes, braces, brackets) appearing inside string literals. + /// + + ZString findRelative(ZString path); + JSONType typeAtCursor(); + void skipValue(); + void skipString(); + void skipContainer(); + void skipNumber(); + ZString captureString(); + ZString captureContainer(); + bool isLiteralValue(ZString value); + }; }