diff --git a/commons-serialize-gson/build.gradle.kts b/commons-serialize-gson/build.gradle.kts index 36d7ae8..59f86ee 100644 --- a/commons-serialize-gson/build.gradle.kts +++ b/commons-serialize-gson/build.gradle.kts @@ -7,7 +7,7 @@ plugins { dependencies { api(libs.gson) implementation(projects.commons) - implementation(projects.commonsSerialize) + api(projects.commonsSerialize) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) diff --git a/commons-serialize-json/build.gradle.kts b/commons-serialize-json/build.gradle.kts new file mode 100644 index 0000000..1951e9f --- /dev/null +++ b/commons-serialize-json/build.gradle.kts @@ -0,0 +1,31 @@ +import io.gitlab.jfronny.scripts.* + +plugins { + commons.library +} + +dependencies { + implementation(projects.commons) + api(projects.commonsSerialize) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.google.truth) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.vintage) +} + +publishing { + publications { + create("maven") { + groupId = "io.gitlab.jfronny" + artifactId = "commons-serialize-json" + + from(components["java"]) + } + } +} + +tasks.javadoc { + linksOffline("https://maven.frohnmeyer-wds.de/javadoc/artifacts/io/gitlab/jfronny/commons/$version/raw", projects.commons) + linksOffline("https://maven.frohnmeyer-wds.de/javadoc/artifacts/io/gitlab/jfronny/commons-serialize/$version/raw", projects.commonsSerialize) +} diff --git a/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/JsonReader.java b/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/JsonReader.java new file mode 100644 index 0000000..a554d3f --- /dev/null +++ b/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/JsonReader.java @@ -0,0 +1,1444 @@ +package io.gitlab.jfronny.commons.serialize.json; + +import io.gitlab.jfronny.commons.data.LazilyParsedNumber; +import io.gitlab.jfronny.commons.serialize.StringEscapeUtil; +import io.gitlab.jfronny.commons.serialize.json.impl.JsonScope; +import io.gitlab.jfronny.commons.serialize.stream.MalformedDataException; +import io.gitlab.jfronny.commons.serialize.stream.SerializeReader; +import io.gitlab.jfronny.commons.serialize.stream.Token; + +import java.io.Closeable; +import java.io.EOFException; +import java.io.IOException; +import java.io.Reader; +import java.util.Arrays; +import java.util.Objects; + +public class JsonReader extends SerializeReader implements Closeable { + private static final long MIN_INCOMPLETE_INTEGER = Long.MIN_VALUE / 10; + + private static final int PEEKED_NONE = 0; + private static final int PEEKED_BEGIN_OBJECT = 1; + private static final int PEEKED_END_OBJECT = 2; + private static final int PEEKED_BEGIN_ARRAY = 3; + private static final int PEEKED_END_ARRAY = 4; + private static final int PEEKED_TRUE = 5; + private static final int PEEKED_FALSE = 6; + private static final int PEEKED_NULL = 7; + private static final int PEEKED_SINGLE_QUOTED = 8; + private static final int PEEKED_DOUBLE_QUOTED = 9; + private static final int PEEKED_UNQUOTED = 10; + + /** When this is returned, the string value is stored in peekedString. */ + private static final int PEEKED_BUFFERED = 11; + + private static final int PEEKED_SINGLE_QUOTED_NAME = 12; + private static final int PEEKED_DOUBLE_QUOTED_NAME = 13; + private static final int PEEKED_UNQUOTED_NAME = 14; + + /** When this is returned, the integer value is stored in peekedLong. */ + private static final int PEEKED_LONG = 15; + + private static final int PEEKED_NUMBER = 16; + private static final int PEEKED_EOF = 17; + + /* State machine when parsing numbers */ + private static final int NUMBER_CHAR_NONE = 0; + private static final int NUMBER_CHAR_SIGN = 1; + private static final int NUMBER_CHAR_DIGIT = 2; + private static final int NUMBER_CHAR_DECIMAL = 3; + private static final int NUMBER_CHAR_FRACTION_DIGIT = 4; + private static final int NUMBER_CHAR_EXP_E = 5; + private static final int NUMBER_CHAR_EXP_SIGN = 6; + private static final int NUMBER_CHAR_EXP_DIGIT = 7; + + /** The input JSON. */ + private final Reader in; + + static final int BUFFER_SIZE = 1024; + /** + * Use a manual buffer to easily read and unread upcoming characters, and also so we can create + * strings without an intermediate StringBuilder. We decode literals directly out of this buffer, + * so it must be at least as long as the longest token that can be reported as a number. + */ + private final char[] buffer = new char[BUFFER_SIZE]; + + private int pos = 0; + private int limit = 0; + + private int lineNumber = 0; + private int lineStart = 0; + + int peeked = PEEKED_NONE; + + /** + * A peeked value that was composed entirely of digits with an optional leading dash. Positive + * values may not have a leading 0. + */ + private long peekedLong; + + /** + * The number of characters in a peeked number literal. Increment 'pos' by this after reading a + * number. + */ + private int peekedNumberLength; + + /** + * A peeked string that should be parsed on the next double, long or string. This is populated + * before a numeric value is parsed and used if that parsing fails. + */ + private String peekedString; + + /* + * The nesting stack. Using a manual array rather than an ArrayList saves 20%. + */ + private int[] stack = new int[32]; + private int stackSize = 0; + + { + stack[stackSize++] = JsonScope.EMPTY_DOCUMENT; + } + + /* + * The path members. It corresponds directly to stack: At indices where the + * stack contains an object (EMPTY_OBJECT, DANGLING_NAME or NONEMPTY_OBJECT), + * pathNames contains the name at this scope. Where it contains an array + * (EMPTY_ARRAY, NONEMPTY_ARRAY) pathIndices contains the current index in + * that array. Otherwise the value is undefined, and we take advantage of that + * by incrementing pathIndices when doing so isn't useful. + */ + private String[] pathNames = new String[32]; + private int[] pathIndices = new int[32]; + + /** Creates a new instance that reads a JSON-encoded stream from {@code in}. */ + public JsonReader(Reader in) { + this.in = Objects.requireNonNull(in, "in == null"); + } + + @Override + public JsonReader beginArray() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + if (p == PEEKED_BEGIN_ARRAY) { + push(JsonScope.EMPTY_ARRAY); + pathIndices[stackSize - 1] = 0; + peeked = PEEKED_NONE; + return this; + } else { + throw unexpectedTokenError("BEGIN_ARRAY"); + } + } + + @Override + public JsonReader endArray() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + if (p == PEEKED_END_ARRAY) { + stackSize--; + pathIndices[stackSize - 1]++; + peeked = PEEKED_NONE; + return this; + } else { + throw unexpectedTokenError("END_ARRAY"); + } + } + + @Override + public JsonReader beginObject() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + if (p == PEEKED_BEGIN_OBJECT) { + push(JsonScope.EMPTY_OBJECT); + peeked = PEEKED_NONE; + return this; + } else { + throw unexpectedTokenError("BEGIN_OBJECT"); + } + } + + @Override + public JsonReader endObject() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + if (p == PEEKED_END_OBJECT) { + stackSize--; + pathNames[stackSize] = null; // Free the last path name so that it can be garbage collected! + pathIndices[stackSize - 1]++; + peeked = PEEKED_NONE; + return this; + } else { + throw unexpectedTokenError("END_OBJECT"); + } + } + + @Override + public boolean hasNext() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + return p != PEEKED_END_OBJECT && p != PEEKED_END_ARRAY && p != PEEKED_EOF; + } + + @Override + public Token peek() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + + switch (p) { + case PEEKED_BEGIN_OBJECT: + return Token.BEGIN_OBJECT; + case PEEKED_END_OBJECT: + return Token.END_OBJECT; + case PEEKED_BEGIN_ARRAY: + return Token.BEGIN_ARRAY; + case PEEKED_END_ARRAY: + return Token.END_ARRAY; + case PEEKED_SINGLE_QUOTED_NAME: + case PEEKED_DOUBLE_QUOTED_NAME: + case PEEKED_UNQUOTED_NAME: + return Token.NAME; + case PEEKED_TRUE: + case PEEKED_FALSE: + return Token.BOOLEAN; + case PEEKED_NULL: + return Token.NULL; + case PEEKED_SINGLE_QUOTED: + case PEEKED_DOUBLE_QUOTED: + case PEEKED_UNQUOTED: + case PEEKED_BUFFERED: + return Token.STRING; + case PEEKED_LONG: + case PEEKED_NUMBER: + return Token.NUMBER; + case PEEKED_EOF: + return Token.END_DOCUMENT; + default: + throw new AssertionError(); + } + } + + @SuppressWarnings("fallthrough") + int doPeek() throws IOException { + int peekStack = stack[stackSize - 1]; + if (peekStack == JsonScope.EMPTY_ARRAY) { + stack[stackSize - 1] = JsonScope.NONEMPTY_ARRAY; + } else if (peekStack == JsonScope.NONEMPTY_ARRAY) { + // Look for a comma before the next element. + int c = nextNonWhitespace(true); + switch (c) { + case ']': + return peeked = PEEKED_END_ARRAY; + case ';': + checkLenient(); // fall-through + case ',': + break; + default: + throw syntaxError("Unterminated array at " + (char)c); + } + } else if (peekStack == JsonScope.EMPTY_OBJECT || peekStack == JsonScope.NONEMPTY_OBJECT) { + stack[stackSize - 1] = JsonScope.DANGLING_NAME; + // Look for a comma before the next element. + if (peekStack == JsonScope.NONEMPTY_OBJECT) { + int c = nextNonWhitespace(true); + switch (c) { + case '}': + return peeked = PEEKED_END_OBJECT; + case ';': + checkLenient(); // fall-through + case ',': + break; + default: + throw syntaxError("Unterminated object at " + (char)c); + } + } + int c = nextNonWhitespace(true); + switch (c) { + case '"': + return peeked = PEEKED_DOUBLE_QUOTED_NAME; + case '\'': + checkLenient(); + return peeked = PEEKED_SINGLE_QUOTED_NAME; + case '}': + if (peekStack != JsonScope.NONEMPTY_OBJECT) { + return peeked = PEEKED_END_OBJECT; + } else { + throw syntaxError("Expected name"); + } + default: + checkLenient(); + pos--; // Don't consume the first character in an unquoted string. + if (isLiteral((char) c)) { + return peeked = PEEKED_UNQUOTED_NAME; + } else { + throw syntaxError("Expected name"); + } + } + } else if (peekStack == JsonScope.DANGLING_NAME) { + stack[stackSize - 1] = JsonScope.NONEMPTY_OBJECT; + // Look for a colon before the value. + int c = nextNonWhitespace(true); + switch (c) { + case ':': + break; + case '=': + checkLenient(); + if ((pos < limit || fillBuffer(1)) && buffer[pos] == '>') { + pos++; + } + break; + default: + throw syntaxError("Expected ':'"); + } + } else if (peekStack == JsonScope.EMPTY_DOCUMENT) { + if (lenient) { + consumeNonExecutePrefix(); + } + stack[stackSize - 1] = JsonScope.NONEMPTY_DOCUMENT; + } else if (peekStack == JsonScope.NONEMPTY_DOCUMENT) { + int c = nextNonWhitespace(false); + if (c == -1) { + return peeked = PEEKED_EOF; + } else { + checkLenient(); + pos--; + } + } else if (peekStack == JsonScope.CLOSED) { + throw new IllegalStateException("JsonReader is closed"); + } + + if (checkNextNonWhitespace(peekStack)) + return peeked; + + int result = peekKeyword(); + if (result != PEEKED_NONE) { + return result; + } + + result = peekNumber(); + if (result != PEEKED_NONE) { + return result; + } + + if (!isLiteral(buffer[pos])) { + throw syntaxError("Expected value"); + } + + checkLenient(); + return peeked = PEEKED_UNQUOTED; + } + + private boolean checkNextNonWhitespace(int peekStack) throws IOException { + int c = nextNonWhitespace(true); + switch (c) { + case ']': + if (peekStack == JsonScope.EMPTY_ARRAY || peekStack == JsonScope.NONEMPTY_ARRAY) { + peeked = PEEKED_END_ARRAY; + return true; + } + throw syntaxError("Unexpected value"); + case ';': + case ',': + // In lenient mode, a 0-length literal in an array should be skipped. + if (peekStack == JsonScope.EMPTY_ARRAY || peekStack == JsonScope.NONEMPTY_ARRAY) { + checkLenient(); + //pos--; + //peeked = PEEKED_NULL; + return checkNextNonWhitespace(peekStack); + } else { + throw syntaxError("Unexpected value"); + } + case '\'': + checkLenient(); + peeked = PEEKED_SINGLE_QUOTED; + return true; + case '"': + peeked = PEEKED_DOUBLE_QUOTED; + return true; + case '[': + peeked = PEEKED_BEGIN_ARRAY; + return true; + case '{': + peeked = PEEKED_BEGIN_OBJECT; + return true; + default: + pos--; // Don't consume the first character in a literal value. + } + return false; + } + + private int peekKeyword() throws IOException { + // Figure out which keyword we're matching against by its first character. + char c = buffer[pos]; + String keyword; + String keywordUpper; + int peeking; + + // Look at the first letter to determine what keyword we are trying to match. + if (c == 't' || c == 'T') { + keyword = "true"; + keywordUpper = "TRUE"; + peeking = PEEKED_TRUE; + } else if (c == 'f' || c == 'F') { + keyword = "false"; + keywordUpper = "FALSE"; + peeking = PEEKED_FALSE; + } else if (c == 'n' || c == 'N') { + keyword = "null"; + keywordUpper = "NULL"; + peeking = PEEKED_NULL; + } else { + return PEEKED_NONE; + } + + // Uppercased keywords are not allowed in STRICT mode + boolean allowsUpperCased = lenient; + + // Confirm that chars [0..length) match the keyword. + int length = keyword.length(); + for (int i = 0; i < length; i++) { + if (pos + i >= limit && !fillBuffer(i + 1)) { + return PEEKED_NONE; + } + c = buffer[pos + i]; + boolean matched = c == keyword.charAt(i) || (allowsUpperCased && c == keywordUpper.charAt(i)); + if (!matched) { + return PEEKED_NONE; + } + } + + if ((pos + length < limit || fillBuffer(length + 1)) && isLiteral(buffer[pos + length])) { + return PEEKED_NONE; // Don't match trues, falsey or nullsoft! + } + + // We've found the keyword followed either by EOF or by a non-literal character. + pos += length; + return peeked = peeking; + } + + private int peekNumber() throws IOException { + // Like nextNonWhitespace, this uses locals 'p' and 'l' to save inner-loop field access. + char[] buffer = this.buffer; + int p = pos; + int l = limit; + + long value = 0; // Negative to accommodate Long.MIN_VALUE more easily. + boolean negative = false; + boolean fitsInLong = true; + int last = NUMBER_CHAR_NONE; + + int i = 0; + + charactersOfNumber: + for (; true; i++) { + if (p + i == l) { + if (i == buffer.length) { + // Though this looks like a well-formed number, it's too long to continue reading. Give up + // and let the application handle this as an unquoted literal. + return PEEKED_NONE; + } + if (!fillBuffer(i + 1)) { + break; + } + p = pos; + l = limit; + } + + char c = buffer[p + i]; + switch (c) { + case '-': + if (last == NUMBER_CHAR_NONE) { + negative = true; + last = NUMBER_CHAR_SIGN; + continue; + } else if (last == NUMBER_CHAR_EXP_E) { + last = NUMBER_CHAR_EXP_SIGN; + continue; + } + return PEEKED_NONE; + + case '+': + if (last == NUMBER_CHAR_EXP_E) { + last = NUMBER_CHAR_EXP_SIGN; + continue; + } + return PEEKED_NONE; + + case 'e': + case 'E': + if (last == NUMBER_CHAR_DIGIT || last == NUMBER_CHAR_FRACTION_DIGIT) { + last = NUMBER_CHAR_EXP_E; + continue; + } + return PEEKED_NONE; + + case '.': + if (last == NUMBER_CHAR_DIGIT) { + last = NUMBER_CHAR_DECIMAL; + continue; + } + return PEEKED_NONE; + + default: + if (c < '0' || c > '9') { + if (!isLiteral(c)) { + break charactersOfNumber; + } + return PEEKED_NONE; + } + if (last == NUMBER_CHAR_SIGN || last == NUMBER_CHAR_NONE) { + value = -(c - '0'); + last = NUMBER_CHAR_DIGIT; + } else if (last == NUMBER_CHAR_DIGIT) { + if (value == 0) { + return PEEKED_NONE; // Leading '0' prefix is not allowed (since it could be octal). + } + long newValue = value * 10 - (c - '0'); + fitsInLong &= + value > MIN_INCOMPLETE_INTEGER + || (value == MIN_INCOMPLETE_INTEGER && newValue < value); + value = newValue; + } else if (last == NUMBER_CHAR_DECIMAL) { + last = NUMBER_CHAR_FRACTION_DIGIT; + } else if (last == NUMBER_CHAR_EXP_E || last == NUMBER_CHAR_EXP_SIGN) { + last = NUMBER_CHAR_EXP_DIGIT; + } + } + } + + // We've read a complete number. Decide if it's a PEEKED_LONG or a PEEKED_NUMBER. + // Don't store -0 as long; user might want to read it as double -0.0 + // Don't try to convert Long.MIN_VALUE to positive long; it would overflow MAX_VALUE + if (last == NUMBER_CHAR_DIGIT + && fitsInLong + && (value != Long.MIN_VALUE || negative) + && (value != 0 || !negative)) { + peekedLong = negative ? value : -value; + pos += i; + return peeked = PEEKED_LONG; + } else if (last == NUMBER_CHAR_DIGIT + || last == NUMBER_CHAR_FRACTION_DIGIT + || last == NUMBER_CHAR_EXP_DIGIT) { + peekedNumberLength = i; + return peeked = PEEKED_NUMBER; + } else { + return PEEKED_NONE; + } + } + + @SuppressWarnings("fallthrough") + private boolean isLiteral(char c) throws IOException { + switch (c) { + case '/': + case '\\': + case ';': + case '#': + case '=': + checkLenient(); // fall-through + case '{': + case '}': + case '[': + case ']': + case ':': + case ',': + case ' ': + case '\t': + case '\f': + case '\r': + case '\n': + return false; + default: + return true; + } + } + + @Override + public String nextName() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + String result; + if (p == PEEKED_UNQUOTED_NAME) { + result = nextUnquotedValue(); + } else if (p == PEEKED_SINGLE_QUOTED_NAME) { + result = nextQuotedValue('\''); + } else if (p == PEEKED_DOUBLE_QUOTED_NAME) { + result = nextQuotedValue('"'); + } else { + throw unexpectedTokenError("a name"); + } + peeked = PEEKED_NONE; + pathNames[stackSize - 1] = result; + return result; + } + + @Override + public String nextString() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + String result; + if (p == PEEKED_UNQUOTED) { + result = nextUnquotedValue(); + } else if (p == PEEKED_SINGLE_QUOTED) { + result = nextQuotedValue('\''); + } else if (p == PEEKED_DOUBLE_QUOTED) { + result = nextQuotedValue('"'); + } else if (p == PEEKED_BUFFERED) { + result = peekedString; + peekedString = null; + } else if (p == PEEKED_LONG) { + result = Long.toString(peekedLong); + } else if (p == PEEKED_NUMBER) { + result = new String(buffer, pos, peekedNumberLength); + pos += peekedNumberLength; + } else { + throw unexpectedTokenError("a string"); + } + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return result; + } + + @Override + public boolean nextBoolean() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + if (p == PEEKED_TRUE) { + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return true; + } else if (p == PEEKED_FALSE) { + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return false; + } + throw unexpectedTokenError("a boolean"); + } + + @Override + public void nextNull() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + if (p == PEEKED_NULL) { + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + } else { + throw unexpectedTokenError("null"); + } + } + + @Override + public double nextDouble() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + + if (p == PEEKED_LONG) { + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return (double) peekedLong; + } + + if (p == PEEKED_NUMBER) { + peekedString = new String(buffer, pos, peekedNumberLength); + pos += peekedNumberLength; + } else if (p == PEEKED_SINGLE_QUOTED || p == PEEKED_DOUBLE_QUOTED) { + peekedString = nextQuotedValue(p == PEEKED_SINGLE_QUOTED ? '\'' : '"'); + } else if (p == PEEKED_UNQUOTED) { + peekedString = nextUnquotedValue(); + } else if (p != PEEKED_BUFFERED) { + throw unexpectedTokenError("a double"); + } + + peeked = PEEKED_BUFFERED; + double result = Double.parseDouble(peekedString); // don't catch this NumberFormatException. + if (!serializeSpecialFloatingPointValues && (Double.isNaN(result) || Double.isInfinite(result))) { + throw syntaxError("JSON forbids NaN and infinities: " + result); + } + peekedString = null; + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return result; + } + + @Override + public long nextLong() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + + if (p == PEEKED_LONG) { + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return peekedLong; + } + + if (p == PEEKED_NUMBER) { + peekedString = new String(buffer, pos, peekedNumberLength); + pos += peekedNumberLength; + } else if (p == PEEKED_SINGLE_QUOTED || p == PEEKED_DOUBLE_QUOTED || p == PEEKED_UNQUOTED) { + if (p == PEEKED_UNQUOTED) { + peekedString = nextUnquotedValue(); + } else { + peekedString = nextQuotedValue(p == PEEKED_SINGLE_QUOTED ? '\'' : '"'); + } + try { + long result = Long.parseLong(peekedString); + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return result; + } catch (NumberFormatException ignored) { + // Fall back to parse as a double below. + } + } else { + throw unexpectedTokenError("a long"); + } + + peeked = PEEKED_BUFFERED; + double asDouble = Double.parseDouble(peekedString); // don't catch this NumberFormatException. + long result = (long) asDouble; + if (result != asDouble) { // Make sure no precision was lost casting to 'long'. + throw new NumberFormatException("Expected a long but was " + peekedString + locationString()); + } + peekedString = null; + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return result; + } + + @Override + public int nextInt() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + + int result; + if (p == PEEKED_LONG) { + result = (int) peekedLong; + if (peekedLong != result) { // Make sure no precision was lost casting to 'int'. + throw new NumberFormatException("Expected an int but was " + peekedLong + locationString()); + } + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return result; + } + + if (p == PEEKED_NUMBER) { + peekedString = new String(buffer, pos, peekedNumberLength); + pos += peekedNumberLength; + } else if (p == PEEKED_SINGLE_QUOTED || p == PEEKED_DOUBLE_QUOTED || p == PEEKED_UNQUOTED) { + if (p == PEEKED_UNQUOTED) { + peekedString = nextUnquotedValue(); + } else { + peekedString = nextQuotedValue(p == PEEKED_SINGLE_QUOTED ? '\'' : '"'); + } + try { + result = Integer.parseInt(peekedString); + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return result; + } catch (NumberFormatException ignored) { + // Fall back to parse as a double below. + } + } else { + throw unexpectedTokenError("an int"); + } + + peeked = PEEKED_BUFFERED; + if (peekedString.startsWith("0x")) { + result = Integer.decode(peekedString); + } else { + double asDouble = Double.parseDouble(peekedString); // don't catch this NumberFormatException. + result = (int) asDouble; + if (result != asDouble) { // Make sure no precision was lost casting to 'int'. + throw new NumberFormatException("Expected an int but was " + peekedString + locationString()); + } + } + peekedString = null; + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return result; + } + + @Override + public Number nextNumber() throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + + if (p == PEEKED_LONG) { + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return (double) peekedLong; + } + + if (p == PEEKED_NUMBER) { + peekedString = new String(buffer, pos, peekedNumberLength); + pos += peekedNumberLength; + } else if (p == PEEKED_SINGLE_QUOTED || p == PEEKED_DOUBLE_QUOTED) { + peekedString = nextQuotedValue(p == PEEKED_SINGLE_QUOTED ? '\'' : '"'); + } else if (p == PEEKED_UNQUOTED) { + peekedString = nextUnquotedValue(); + } else if (p != PEEKED_BUFFERED) { + throw unexpectedTokenError("a double"); + } + + peeked = PEEKED_BUFFERED; + LazilyParsedNumber result = new LazilyParsedNumber(peekedString); // don't catch this NumberFormatException. + if (!serializeSpecialFloatingPointValues + && (result.toString().equals("NaN") + || result.toString().equals("Infinity") || result.toString().equals("-Infinity"))) { + throw syntaxError("JSON forbids NaN and infinities: " + result); + } + peekedString = null; + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + return result; + } + + @Override + public void skipValue() throws IOException { + int count = 0; + do { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + + switch (p) { + case PEEKED_BEGIN_ARRAY: + push(JsonScope.EMPTY_ARRAY); + count++; + break; + case PEEKED_BEGIN_OBJECT: + push(JsonScope.EMPTY_OBJECT); + count++; + break; + case PEEKED_END_ARRAY: + stackSize--; + count--; + break; + case PEEKED_END_OBJECT: + // Only update when object end is explicitly skipped, otherwise stack is not updated + // anyways + if (count == 0) { + // Free the last path name so that it can be garbage collected + pathNames[stackSize - 1] = null; + } + stackSize--; + count--; + break; + case PEEKED_UNQUOTED: + skipUnquotedValue(); + break; + case PEEKED_SINGLE_QUOTED: + skipQuotedValue('\''); + break; + case PEEKED_DOUBLE_QUOTED: + skipQuotedValue('"'); + break; + case PEEKED_UNQUOTED_NAME: + skipUnquotedValue(); + // Only update when name is explicitly skipped, otherwise stack is not updated anyways + if (count == 0) { + pathNames[stackSize - 1] = ""; + } + break; + case PEEKED_SINGLE_QUOTED_NAME: + skipQuotedValue('\''); + // Only update when name is explicitly skipped, otherwise stack is not updated anyways + if (count == 0) { + pathNames[stackSize - 1] = ""; + } + break; + case PEEKED_DOUBLE_QUOTED_NAME: + skipQuotedValue('"'); + // Only update when name is explicitly skipped, otherwise stack is not updated anyways + if (count == 0) { + pathNames[stackSize - 1] = ""; + } + break; + case PEEKED_NUMBER: + pos += peekedNumberLength; + break; + case PEEKED_EOF: + throw new IllegalStateException("Attempt to skip led outside the document"); + default: + // For all other tokens there is nothing to do; token has already been consumed from + // underlying reader + } + peeked = PEEKED_NONE; + } while (count > 0); + + pathIndices[stackSize - 1]++; + if (count < 0) throw new IllegalStateException("Attempt to skip led outside its parent"); + } + + private void push(int newTop) { + if (stackSize == stack.length) { + int newLength = stackSize * 2; + stack = Arrays.copyOf(stack, newLength); + pathIndices = Arrays.copyOf(pathIndices, newLength); + pathNames = Arrays.copyOf(pathNames, newLength); + } + stack[stackSize++] = newTop; + } + + /** + * Returns true once {@code limit - pos >= minimum}. If the data is exhausted before that many + * characters are available, this returns false. + */ + private boolean fillBuffer(int minimum) throws IOException { + char[] buffer = this.buffer; + lineStart -= pos; + if (limit != pos) { + limit -= pos; + System.arraycopy(buffer, pos, buffer, 0, limit); + } else { + limit = 0; + } + + pos = 0; + int total; + while ((total = in.read(buffer, limit, buffer.length - limit)) != -1) { + limit += total; + + // if this is the first read, consume an optional byte order mark (BOM) if it exists + if (lineNumber == 0 && lineStart == 0 && limit > 0 && buffer[0] == '\ufeff') { + pos++; + lineStart++; + minimum++; + } + + if (limit >= minimum) { + return true; + } + } + return false; + } + + /** + * Returns the next character in the stream that is neither whitespace nor a part of a comment. + * When this returns, the returned character is always at {@code buffer[pos-1]}; this means the + * caller can always push back the returned character by decrementing {@code pos}. + */ + private int nextNonWhitespace(boolean throwOnEof) throws IOException { + /* + * This code uses ugly local variables 'p' and 'l' representing the 'pos' + * and 'limit' fields respectively. Using locals rather than fields saves + * a few field reads for each whitespace character in a pretty-printed + * document, resulting in a 5% speedup. We need to flush 'p' to its field + * before any (potentially indirect) call to fillBuffer() and reread both + * 'p' and 'l' after any (potentially indirect) call to the same method. + */ + char[] buffer = this.buffer; + int p = pos; + int l = limit; + while (true) { + if (p == l) { + pos = p; + if (!fillBuffer(1)) { + break; + } + p = pos; + l = limit; + } + + int c = buffer[p++]; + if (c == '\n') { + lineNumber++; + lineStart = p; + continue; + } else if (c == ' ' || c == '\r' || c == '\t') { + continue; + } + + if (c == '/') { + pos = p; + if (p == l) { + pos--; // push back '/' so it's still in the buffer when this method returns + boolean charsLoaded = fillBuffer(2); + pos++; // consume the '/' again + if (!charsLoaded) { + return c; + } + } + + checkLenient(); + char peek = buffer[pos]; + switch (peek) { + case '*': + // skip a /* c-style comment */ + pos++; + if (!skipTo("*/")) { + throw syntaxError("Unterminated comment"); + } + p = pos + 2; + l = limit; + continue; + + case '/': + // skip a // end-of-line comment + pos++; + skipToEndOfLine(); + p = pos; + l = limit; + continue; + + default: + return c; + } + } else if (c == '#') { + pos = p; + /* + * Skip a # hash end-of-line comment. The JSON RFC doesn't + * specify this behaviour, but it's required to parse + * existing documents. See http://b/2571423. + */ + checkLenient(); + skipToEndOfLine(); + p = pos; + l = limit; + } else { + pos = p; + return c; + } + } + if (throwOnEof) { + throw new EOFException("End of input" + locationString()); + } else { + return -1; + } + } + + private void checkLenient() throws MalformedDataException { + if (!lenient) { + throw syntaxError("Use JsonReader.setLenient(true) to accept malformed JSON"); + } + } + + /** + * Advances the position until after the next newline character. If the line is terminated by + * "\r\n", the '\n' must be consumed as whitespace by the caller. + */ + private void skipToEndOfLine() throws IOException { + while (pos < limit || fillBuffer(1)) { + char c = buffer[pos++]; + if (c == '\n') { + lineNumber++; + lineStart = pos; + break; + } else if (c == '\r') { + break; + } + } + } + + /** + * @param toFind a string to search for. Must not contain a newline. + */ + private boolean skipTo(String toFind) throws IOException { + int length = toFind.length(); + outer: + for (; pos + length <= limit || fillBuffer(length); pos++) { + if (buffer[pos] == '\n') { + lineNumber++; + lineStart = pos + 1; + continue; + } + for (int c = 0; c < length; c++) { + if (buffer[pos + c] != toFind.charAt(c)) { + continue outer; + } + } + return true; + } + return false; + } + + /** + * Returns the string up to but not including {@code quote}, unescaping any character escape + * sequences encountered along the way. The opening quote should have already been read. This + * consumes the closing quote, but does not include it in the returned string. + * + * @param quote either ' or ". + */ + private String nextQuotedValue(char quote) throws IOException { + // Like nextNonWhitespace, this uses locals 'p' and 'l' to save inner-loop field access. + char[] buffer = this.buffer; + StringBuilder builder = null; + while (true) { + int p = pos; + int l = limit; + /* the index of the first character not yet appended to the builder. */ + int start = p; + while (p < l) { + int c = buffer[p++]; + + // In strict mode, throw an exception when meeting unescaped control characters (U+0000 + // through U+001F) + if (!lenient && c < 0x20) { + throw syntaxError( + "Unescaped control characters (\\u0000-\\u001F) are not allowed in strict mode"); + } else if (c == quote) { + pos = p; + int len = p - start - 1; + if (builder == null) { + return new String(buffer, start, len); + } else { + builder.append(buffer, start, len); + return builder.toString(); + } + } else if (c == '\\') { + pos = p; + int len = p - start - 1; + if (builder == null) { + int estimatedLength = (len + 1) * 2; + builder = new StringBuilder(Math.max(estimatedLength, 16)); + } + builder.append(buffer, start, len); + builder.append(readEscapeCharacter()); + p = pos; + l = limit; + start = p; + } else if (c == '\n') { + lineNumber++; + lineStart = p; + } + } + + if (builder == null) { + int estimatedLength = (p - start) * 2; + builder = new StringBuilder(Math.max(estimatedLength, 16)); + } + builder.append(buffer, start, p - start); + pos = p; + if (!fillBuffer(1)) { + throw syntaxError("Unterminated string"); + } + } + } + + /** Returns an unquoted value as a string. */ + @SuppressWarnings("fallthrough") + private String nextUnquotedValue() throws IOException { + StringBuilder builder = null; + int i = 0; + + findNonLiteralCharacter: + while (true) { + for (; pos + i < limit; i++) { + switch (buffer[pos + i]) { + case '/': + case '\\': + case ';': + case '#': + case '=': + checkLenient(); // fall-through + case '{': + case '}': + case '[': + case ']': + case ':': + case ',': + case ' ': + case '\t': + case '\f': + case '\r': + case '\n': + break findNonLiteralCharacter; + default: + // skip character to be included in string value + } + } + + // Attempt to load the entire literal into the buffer at once. + if (i < buffer.length) { + if (fillBuffer(i + 1)) { + continue; + } else { + break; + } + } + + // use a StringBuilder when the value is too long. This is too long to be a number! + if (builder == null) { + builder = new StringBuilder(Math.max(i, 16)); + } + builder.append(buffer, pos, i); + pos += i; + i = 0; + if (!fillBuffer(1)) { + break; + } + } + + String result = + (null == builder) ? new String(buffer, pos, i) : builder.append(buffer, pos, i).toString(); + pos += i; + return result; + } + + private void skipQuotedValue(char quote) throws IOException { + // Like nextNonWhitespace, this uses locals 'p' and 'l' to save inner-loop field access. + char[] buffer = this.buffer; + do { + int p = pos; + int l = limit; + /* the index of the first character not yet appended to the builder. */ + while (p < l) { + int c = buffer[p++]; + if (c == quote) { + pos = p; + return; + } else if (c == '\\') { + pos = p; + char unused = readEscapeCharacter(); + p = pos; + l = limit; + } else if (c == '\n') { + lineNumber++; + lineStart = p; + } + } + pos = p; + } while (fillBuffer(1)); + throw syntaxError("Unterminated string"); + } + + @SuppressWarnings("fallthrough") + private void skipUnquotedValue() throws IOException { + do { + int i = 0; + for (; pos + i < limit; i++) { + switch (buffer[pos + i]) { + case '/': + case '\\': + case ';': + case '#': + case '=': + checkLenient(); // fall-through + case '{': + case '}': + case '[': + case ']': + case ':': + case ',': + case ' ': + case '\t': + case '\f': + case '\r': + case '\n': + pos += i; + return; + default: + // skip the character + } + } + pos += i; + } while (fillBuffer(1)); + } + + @Override + protected String locationString() { + int line = lineNumber + 1; + int column = pos - lineStart + 1; + String replacement = StringEscapeUtil.getReplacement(buffer[pos]); + if (replacement == null) { + replacement = String.valueOf(buffer[pos]); + } + String charInterjection = pos < buffer.length ? " (char '" + replacement + "')" : ""; + return " at line " + line + " column " + column + charInterjection + " path " + getPath(); + } + + private String getPath(boolean usePreviousPath) { + StringBuilder result = new StringBuilder().append('$'); + for (int i = 0; i < stackSize; i++) { + int scope = stack[i]; + switch (scope) { + case JsonScope.EMPTY_ARRAY: + case JsonScope.NONEMPTY_ARRAY: + int pathIndex = pathIndices[i]; + // If index is last path element it points to next array element; have to decrement + if (usePreviousPath && pathIndex > 0 && i == stackSize - 1) { + pathIndex--; + } + result.append('[').append(pathIndex).append(']'); + break; + case JsonScope.EMPTY_OBJECT: + case JsonScope.DANGLING_NAME: + case JsonScope.NONEMPTY_OBJECT: + result.append('.'); + if (pathNames[i] != null) { + result.append(pathNames[i]); + } + break; + case JsonScope.NONEMPTY_DOCUMENT: + case JsonScope.EMPTY_DOCUMENT: + case JsonScope.CLOSED: + break; + default: + throw new AssertionError("Unknown scope value: " + scope); + } + } + return result.toString(); + } + + @Override + public String getPath() { + return getPath(false); + } + + @Override + public String getPreviousPath() { + return getPath(true); + } + + /** + * Unescapes the character identified by the character or characters that immediately follow a + * backslash. The backslash '\' should have already been read. This supports both Unicode escapes + * "u000A" and two-character escapes "\n". + * + * @throws MalformedDataException if the escape sequence is malformed + */ + @SuppressWarnings("fallthrough") + private char readEscapeCharacter() throws IOException { + if (pos == limit && !fillBuffer(1)) { + throw syntaxError("Unterminated escape sequence"); + } + + char escaped = buffer[pos++]; + switch (escaped) { + case 'u': + if (pos + 4 > limit && !fillBuffer(4)) { + throw syntaxError("Unterminated escape sequence"); + } + // Equivalent to Integer.parseInt(stringPool.get(buffer, pos, 4), 16); + int result = 0; + for (int i = pos, end = i + 4; i < end; i++) { + char c = buffer[i]; + result <<= 4; + if (c >= '0' && c <= '9') { + result += (c - '0'); + } else if (c >= 'a' && c <= 'f') { + result += (c - 'a' + 10); + } else if (c >= 'A' && c <= 'F') { + result += (c - 'A' + 10); + } else { + throw syntaxError("Malformed Unicode escape \\u" + new String(buffer, pos, 4)); + } + } + pos += 4; + return (char) result; + + case 't': + return '\t'; + + case 'b': + return '\b'; + + case 'n': + return '\n'; + + case 'r': + return '\r'; + + case 'f': + return '\f'; + + case '\n': + if (!lenient) { + throw syntaxError("Cannot escape a newline character in strict mode"); + } + lineNumber++; + lineStart = pos; + // fall-through + + case '\'': + if (!lenient) { + throw syntaxError("Invalid escaped character \"'\" in strict mode"); + } + case '"': + case '\\': + case '/': + return escaped; + default: + // throw error when none of the above cases are matched + throw syntaxError("Invalid escape sequence"); + } + } + + /** + * Throws a new {@link MalformedDataException} with the given message and information about the + * current location. + */ + private MalformedDataException syntaxError(String message) throws MalformedDataException { + throw new MalformedDataException(message + locationString()); + } + + private IllegalStateException unexpectedTokenError(String expected) throws IOException { + Token peeked = peek(); + String troubleshootingId = + peeked == Token.NULL ? "adapter-not-null-safe" : "unexpected-json-structure"; + return new IllegalStateException( + "Expected " + + expected + + " but was " + + peek() + + locationString() + ); + } + + /** Consumes the non-execute prefix if it exists. */ + private void consumeNonExecutePrefix() throws IOException { + // fast-forward through the leading whitespace + int unused = nextNonWhitespace(true); + pos--; + + if (pos + 5 > limit && !fillBuffer(5)) { + return; + } + + int p = pos; + char[] buf = buffer; + if (buf[p] != ')' + || buf[p + 1] != ']' + || buf[p + 2] != '}' + || buf[p + 3] != '\'' + || buf[p + 4] != '\n') { + return; // not a security token! + } + + // we consumed a security token! + pos += 5; + } + + @Override + public void close() throws IOException { + peeked = PEEKED_NONE; + stack[0] = JsonScope.CLOSED; + stackSize = 1; + in.close(); + } +} diff --git a/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/JsonWriter.java b/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/JsonWriter.java new file mode 100644 index 0000000..b4ca877 --- /dev/null +++ b/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/JsonWriter.java @@ -0,0 +1,350 @@ +package io.gitlab.jfronny.commons.serialize.json; + +import io.gitlab.jfronny.commons.serialize.StringEscapeUtil; +import io.gitlab.jfronny.commons.serialize.stream.MalformedDataException; +import io.gitlab.jfronny.commons.serialize.stream.SerializeWriter; + +import java.io.Closeable; +import java.io.IOException; +import java.io.Writer; +import java.util.*; + +import static io.gitlab.jfronny.commons.serialize.json.impl.JsonScope.*; + +public class JsonWriter extends SerializeWriter implements Closeable { + private final Writer out; + private int[] stack = new int[32]; + private int stackSize = 0; + + { + push(EMPTY_DOCUMENT); + } + + private String newline; + private String indent; + private String formattedColon; + private String formattedComma; + private boolean usesEmptyNewlineAndIndent; + + private boolean omitQuotes = false; + private String deferredName; + private List deferredComments = new LinkedList<>(); + + public JsonWriter(Writer out) { + this.out = Objects.requireNonNull(out, "out == null"); + newline = indent = ""; + setIndent(""); + setNewline(""); + } + + public JsonWriter setIndent(String indent) { + if (indent == null || indent.isEmpty()) { + this.indent = ""; + this.formattedColon = ":"; + this.formattedComma = ","; + this.usesEmptyNewlineAndIndent = newline.isEmpty(); + } else { + this.newline = "\n"; // if someone sets an indent, this is probably intended + this.indent = indent; + this.formattedColon = ": "; + this.formattedComma = ","; + this.usesEmptyNewlineAndIndent = false; + } + return this; + } + + public String getIndent() { + return indent; + } + + public JsonWriter setNewline(String newline) { + if (newline == null || newline.isEmpty()) { + this.newline = ""; + this.formattedComma = indent.isEmpty() ? "," : ", "; + this.usesEmptyNewlineAndIndent = indent.isEmpty(); + } else { + this.newline = newline; + this.formattedComma = ","; + this.usesEmptyNewlineAndIndent = false; + } + return this; + } + + public String getNewline() { + return newline; + } + + @Override + public JsonWriter beginArray() throws IOException { + writeDeferredName(); + return openScope(EMPTY_ARRAY, '['); + } + + @Override + public JsonWriter endArray() throws IOException { + return closeScope(EMPTY_ARRAY, NONEMPTY_ARRAY, ']'); + } + + @Override + public JsonWriter beginObject() throws IOException { + writeDeferredName(); + return openScope(EMPTY_OBJECT, '{'); + } + + @Override + public JsonWriter endObject() throws IOException { + return closeScope(EMPTY_OBJECT, NONEMPTY_OBJECT, '}'); + } + + private JsonWriter openScope(int empty, char openBracket) throws IOException { + beforeValue(); + push(empty); + out.write(openBracket); + return this; + } + + private JsonWriter closeScope(int empty, int nonempty, char closeBracket) throws IOException { + int context = peek(); + if (context != nonempty && context != empty) { + throw new IllegalStateException("Nesting problem."); + } + if (deferredName != null) { + throw new IllegalStateException("Dangling name: " + deferredName); + } + + if (!deferredComments.isEmpty()) { + newline(); + writeDeferredComment(); + context = nonempty; + } + + stackSize--; + if (context == nonempty) { + newline(); + } + out.write(closeBracket); + return this; + } + + private void push(int newTop) { + if (stackSize == stack.length) { + stack = Arrays.copyOf(stack, stackSize * 2); + } + stack[stackSize++] = newTop; + } + + /** Returns the value on the top of the stack. */ + private int peek() { + if (stackSize == 0) { + throw new IllegalStateException("JsonWriter is closed."); + } + return stack[stackSize - 1]; + } + + /** Replace the value on the top of the stack with the given value. */ + private void replaceTop(int topOfStack) { + stack[stackSize - 1] = topOfStack; + } + + @Override + public JsonWriter comment(String comment) throws IOException { + if (!lenient) throw new MalformedDataException("Cannot write comment in non-lenient JsonWriter."); + if (comment == null || comment.isBlank()) return this; + String[] parts = comment.split("\n"); + Collections.addAll(deferredComments, parts); + if (peek() == NONEMPTY_DOCUMENT) { + newline(); + writeDeferredComment(); + } + return this; + } + + private void writeDeferredComment() throws IOException { + if (!deferredComments.isEmpty()) { + if (newline.isEmpty()) { + out.append("/* ").append(String.join(" / ", deferredComments)).append(" */"); + } else { + boolean first = true; + for (String s : deferredComments) { + if (!first) newline(); + first = false; + out.append("// ").append(s); + } + } + deferredComments.clear(); + } + } + + @Override + public JsonWriter name(String name) throws IOException { + Objects.requireNonNull(name, "name == null"); + if (deferredName != null) { + throw new IllegalStateException("Already wrote a name, expecting a value."); + } + int context = peek(); + if (context != EMPTY_OBJECT && context != NONEMPTY_OBJECT) { + throw new IllegalStateException("Please begin an object before writing a name."); + } + deferredName = name; + return this; + } + + private void writeDeferredName() throws IOException { + if (deferredName != null) { + beforeName(); + if (omitQuotes && deferredName.matches("[a-zA-Z_$][\\w$]*")) { + out.write(deferredName); + } + else { + string(deferredName); + } + deferredName = null; + } + } + + @Override + public JsonWriter value(String value) throws IOException { + if (value == null) { + return nullValue(); + } + writeDeferredName(); + beforeValue(); + string(value); + return this; + } + + @Override + public JsonWriter literalValue(String value) throws IOException { + if (value == null) { + return nullValue(); + } + writeDeferredName(); + beforeValue(); + out.append(value); + return this; + } + + @Override + public void flush() throws IOException { + if (stackSize == 0) { + throw new IllegalStateException("JsonWriter is closed."); + } + out.flush(); + } + + @Override + public void close() throws IOException { + out.close(); + + int size = stackSize; + if (size > 1 || (size == 1 && stack[size - 1] != NONEMPTY_DOCUMENT)) { + throw new IOException("Incomplete document"); + } + stackSize = 0; + } + + private void string(String value) throws IOException { + out.write('\"'); + int last = 0; + int length = value.length(); + for (int i = 0; i < length; i++) { + char c = value.charAt(i); + String replacement = StringEscapeUtil.getReplacement(c); + if (replacement == null) { + continue; + } + if (last < i) { + out.write(value, last, i - last); + } + out.write(replacement); + last = i + 1; + } + if (last < length) { + out.write(value, last, length - last); + } + out.write('\"'); + } + + private void newline() throws IOException { + if (usesEmptyNewlineAndIndent) { + return; + } + + out.write(newline); + for (int i = 1, size = stackSize; i < size; i++) { + out.write(indent); + } + } + + /** + * Inserts any necessary separators and whitespace before a name. Also adjusts the stack to expect + * the name's value. + */ + private void beforeName() throws IOException { + int context = peek(); + if (context == NONEMPTY_OBJECT) { // first in object + out.write(formattedComma); + } else if (context != EMPTY_OBJECT) { // not in an object! + throw new IllegalStateException("Nesting problem."); + } + newline(); + if (!deferredComments.isEmpty()) { + writeDeferredComment(); + newline(); + } + replaceTop(DANGLING_NAME); + } + + /** + * Inserts any necessary separators and whitespace before a literal value, inline array, or inline + * object. Also adjusts the stack to expect either a closing bracket or another element. + */ + @SuppressWarnings("fallthrough") + private void beforeValue() throws IOException { + switch (peek()) { + case NONEMPTY_DOCUMENT: + if (!lenient) { + throw new IllegalStateException("JSON must have only one top-level value."); + } + // fall-through + case EMPTY_DOCUMENT: // first in document + replaceTop(NONEMPTY_DOCUMENT); + if (!deferredComments.isEmpty()) { + writeDeferredComment(); + newline(); + } + break; + + case EMPTY_ARRAY: // first in array + replaceTop(NONEMPTY_ARRAY); + newline(); + if (!deferredComments.isEmpty()) { + writeDeferredComment(); + newline(); + } + break; + + case NONEMPTY_ARRAY: // another in array + out.append(formattedComma); + newline(); + if (!deferredComments.isEmpty()) { + writeDeferredComment(); + newline(); + } + break; + + case DANGLING_NAME: // value for name + out.append(formattedColon); + if (!deferredComments.isEmpty()) { + newline(); + writeDeferredComment(); + newline(); + } + replaceTop(NONEMPTY_OBJECT); + break; + + default: + throw new IllegalStateException("Nesting problem."); + } + } +} diff --git a/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/impl/JsonScope.java b/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/impl/JsonScope.java new file mode 100644 index 0000000..bf89df3 --- /dev/null +++ b/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/impl/JsonScope.java @@ -0,0 +1,27 @@ +package io.gitlab.jfronny.commons.serialize.json.impl; + +public class JsonScope { + /** An array with no elements requires no separator before the next element. */ + public static final int EMPTY_ARRAY = 1; + + /** An array with at least one value requires a separator before the next element. */ + public static final int NONEMPTY_ARRAY = 2; + + /** An object with no name/value pairs requires no separator before the next element. */ + public static final int EMPTY_OBJECT = 3; + + /** An object whose most recent element is a key. The next element must be a value. */ + public static final int DANGLING_NAME = 4; + + /** An object with at least one name/value pair requires a separator before the next element. */ + public static final int NONEMPTY_OBJECT = 5; + + /** No top-level value has been started yet. */ + public static final int EMPTY_DOCUMENT = 6; + + /** A top-level value has already been started. */ + public static final int NONEMPTY_DOCUMENT = 7; + + /** A document that's been closed and cannot be accessed. */ + public static final int CLOSED = 8; +} diff --git a/commons-serialize-json/src/main/java/module-info.java b/commons-serialize-json/src/main/java/module-info.java new file mode 100644 index 0000000..748cc61 --- /dev/null +++ b/commons-serialize-json/src/main/java/module-info.java @@ -0,0 +1,6 @@ +module io.gitlab.jfronny.commons.serialize.json { + requires io.gitlab.jfronny.commons; + requires io.gitlab.jfronny.commons.serialize; + requires static org.jetbrains.annotations; + exports io.gitlab.jfronny.commons.serialize.json; +} \ No newline at end of file diff --git a/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonReaderPathTest.java b/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonReaderPathTest.java new file mode 100644 index 0000000..17e6126 --- /dev/null +++ b/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonReaderPathTest.java @@ -0,0 +1,429 @@ +/* + * Copyright (C) 2014 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.gitlab.jfronny.commons.serialize.json.test; + +import io.gitlab.jfronny.commons.serialize.json.JsonReader; +import io.gitlab.jfronny.commons.serialize.stream.SerializeReader; +import io.gitlab.jfronny.commons.serialize.stream.Token; +import io.gitlab.jfronny.commons.serialize.stream.emulated.DataElementSerializer; +import io.gitlab.jfronny.commons.serialize.stream.emulated.EmulatedReader; +import io.gitlab.jfronny.commons.throwable.Try; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Arrays; +import java.util.List; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeTrue; + +@SuppressWarnings("resource") +@RunWith(Parameterized.class) +public class JsonReaderPathTest { + @Parameterized.Parameters(name = "{0}") + public static List parameters() { + return Arrays.asList( + new Object[] {Factory.STRING_READER}, new Object[] {Factory.OBJECT_READER}); + } + + @Parameterized.Parameter public Factory factory; + + @Test + public void path() throws IOException { + SerializeReader reader = factory.create("{\"a\":[2,true,false,null,\"b\",{\"c\":\"d\"},[3]]}"); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$."); + assertThat(reader.getPath()).isEqualTo("$."); + String unused1 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[0]"); + assertThat(reader.getPath()).isEqualTo("$.a[0]"); + int unused2 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[0]"); + assertThat(reader.getPath()).isEqualTo("$.a[1]"); + boolean unused3 = reader.nextBoolean(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[1]"); + assertThat(reader.getPath()).isEqualTo("$.a[2]"); + boolean unused4 = reader.nextBoolean(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[2]"); + assertThat(reader.getPath()).isEqualTo("$.a[3]"); + reader.nextNull(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[3]"); + assertThat(reader.getPath()).isEqualTo("$.a[4]"); + String unused5 = reader.nextString(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[4]"); + assertThat(reader.getPath()).isEqualTo("$.a[5]"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[5]."); + assertThat(reader.getPath()).isEqualTo("$.a[5]."); + String unused6 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[5].c"); + assertThat(reader.getPath()).isEqualTo("$.a[5].c"); + String unused7 = reader.nextString(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[5].c"); + assertThat(reader.getPath()).isEqualTo("$.a[5].c"); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[5]"); + assertThat(reader.getPath()).isEqualTo("$.a[6]"); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[6][0]"); + assertThat(reader.getPath()).isEqualTo("$.a[6][0]"); + int unused8 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[6][0]"); + assertThat(reader.getPath()).isEqualTo("$.a[6][1]"); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a[6]"); + assertThat(reader.getPath()).isEqualTo("$.a[7]"); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void objectPath() throws IOException { + SerializeReader reader = factory.create("{\"a\":1,\"b\":2}"); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + + Token unused1 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$."); + assertThat(reader.getPath()).isEqualTo("$."); + + Token unused2 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$."); + assertThat(reader.getPath()).isEqualTo("$."); + String unused3 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + + Token unused4 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + int unused5 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + + Token unused6 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + String unused7 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b"); + assertThat(reader.getPath()).isEqualTo("$.b"); + + Token unused8 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b"); + assertThat(reader.getPath()).isEqualTo("$.b"); + int unused9 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b"); + assertThat(reader.getPath()).isEqualTo("$.b"); + + Token unused10 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b"); + assertThat(reader.getPath()).isEqualTo("$.b"); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + + Token unused11 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + Try.orThrow(reader::close); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void arrayPath() throws IOException { + SerializeReader reader = factory.create("[1,2]"); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + + Token unused1 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[0]"); + + Token unused2 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[0]"); + int unused3 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[1]"); + + Token unused4 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[1]"); + int unused5 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$[1]"); + assertThat(reader.getPath()).isEqualTo("$[2]"); + + Token unused6 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$[1]"); + assertThat(reader.getPath()).isEqualTo("$[2]"); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + + Token unused7 = reader.peek(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + Try.orThrow(reader::close); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void multipleTopLevelValuesInOneDocument() throws IOException { + assumeTrue(factory == Factory.STRING_READER); + + SerializeReader reader = factory.create("[][]"); + reader.setLenient(true); + reader.beginArray(); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + reader.beginArray(); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void skipArrayElements() throws IOException { + SerializeReader reader = factory.create("[1,2,3]"); + reader.beginArray(); + reader.skipValue(); + reader.skipValue(); + assertThat(reader.getPreviousPath()).isEqualTo("$[1]"); + assertThat(reader.getPath()).isEqualTo("$[2]"); + } + + @Test + public void skipArrayEnd() throws IOException { + SerializeReader reader = factory.create("[[],1]"); + reader.beginArray(); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0][0]"); + assertThat(reader.getPath()).isEqualTo("$[0][0]"); + assertThrows("Attempt to skip led outside its parent", IllegalStateException.class, reader::skipValue); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[1]"); + } + + @Test + public void skipObjectNames() throws IOException { + SerializeReader reader = factory.create("{\"a\":[]}"); + reader.beginObject(); + reader.skipValue(); + assertThat(reader.getPreviousPath()).isEqualTo("$."); + assertThat(reader.getPath()).isEqualTo("$."); + + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$.[0]"); + assertThat(reader.getPath()).isEqualTo("$.[0]"); + } + + @Test + public void skipObjectValues() throws IOException { + SerializeReader reader = factory.create("{\"a\":1,\"b\":2}"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$."); + assertThat(reader.getPath()).isEqualTo("$."); + String unused1 = reader.nextName(); + reader.skipValue(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + String unused2 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b"); + assertThat(reader.getPath()).isEqualTo("$.b"); + } + + @Test + public void skipObjectEnd() throws IOException { + SerializeReader reader = factory.create("{\"a\":{},\"b\":2}"); + reader.beginObject(); + String unused = reader.nextName(); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a."); + assertThat(reader.getPath()).isEqualTo("$.a."); + // skip end of object + assertThat(reader.peek()).isEqualTo(Token.END_OBJECT); + assertThrows("Attempt to skip led outside its parent", IllegalStateException.class, reader::skipValue); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + } + + @Test + public void skipNestedStructures() throws IOException { + SerializeReader reader = factory.create("[[1,2,3],4]"); + reader.beginArray(); + reader.skipValue(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[1]"); + } + + @Test + public void skipEndOfDocument() throws IOException { + SerializeReader reader = factory.create("[]"); + reader.beginArray(); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + assertThrows("Attempt to skip led outside the document", IllegalStateException.class, reader::skipValue); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void arrayOfObjects() throws IOException { + SerializeReader reader = factory.create("[{},{},{}]"); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[0]"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]."); + assertThat(reader.getPath()).isEqualTo("$[0]."); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[1]"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$[1]."); + assertThat(reader.getPath()).isEqualTo("$[1]."); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$[1]"); + assertThat(reader.getPath()).isEqualTo("$[2]"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$[2]."); + assertThat(reader.getPath()).isEqualTo("$[2]."); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$[2]"); + assertThat(reader.getPath()).isEqualTo("$[3]"); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void arrayOfArrays() throws IOException { + SerializeReader reader = factory.create("[[],[],[]]"); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[0]"); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0][0]"); + assertThat(reader.getPath()).isEqualTo("$[0][0]"); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[0]"); + assertThat(reader.getPath()).isEqualTo("$[1]"); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[1][0]"); + assertThat(reader.getPath()).isEqualTo("$[1][0]"); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[1]"); + assertThat(reader.getPath()).isEqualTo("$[2]"); + reader.beginArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[2][0]"); + assertThat(reader.getPath()).isEqualTo("$[2][0]"); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$[2]"); + assertThat(reader.getPath()).isEqualTo("$[3]"); + reader.endArray(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void objectOfObjects() throws IOException { + SerializeReader reader = factory.create("{\"a\":{\"a1\":1,\"a2\":2},\"b\":{\"b1\":1}}"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$."); + assertThat(reader.getPath()).isEqualTo("$."); + String unused1 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a."); + assertThat(reader.getPath()).isEqualTo("$.a."); + String unused2 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a.a1"); + assertThat(reader.getPath()).isEqualTo("$.a.a1"); + int unused3 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a.a1"); + assertThat(reader.getPath()).isEqualTo("$.a.a1"); + String unused4 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a.a2"); + assertThat(reader.getPath()).isEqualTo("$.a.a2"); + int unused5 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a.a2"); + assertThat(reader.getPath()).isEqualTo("$.a.a2"); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$.a"); + assertThat(reader.getPath()).isEqualTo("$.a"); + String unused6 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b"); + assertThat(reader.getPath()).isEqualTo("$.b"); + reader.beginObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b."); + assertThat(reader.getPath()).isEqualTo("$.b."); + String unused7 = reader.nextName(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b.b1"); + assertThat(reader.getPath()).isEqualTo("$.b.b1"); + int unused8 = reader.nextInt(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b.b1"); + assertThat(reader.getPath()).isEqualTo("$.b.b1"); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$.b"); + assertThat(reader.getPath()).isEqualTo("$.b"); + reader.endObject(); + assertThat(reader.getPreviousPath()).isEqualTo("$"); + assertThat(reader.getPath()).isEqualTo("$"); + } + + public enum Factory { + STRING_READER { + @Override + public SerializeReader create(String data) { + return new JsonReader(new StringReader(data)); + } + }, + OBJECT_READER { + @Override + public SerializeReader create(String data) { + JsonReader source = new JsonReader(new StringReader(data)); + return new EmulatedReader(Try.orThrow(() -> DataElementSerializer.deserialize(source))); + } + }; + + abstract SerializeReader create(String data); + } +} diff --git a/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonReaderTest.java b/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonReaderTest.java new file mode 100644 index 0000000..c9e7b73 --- /dev/null +++ b/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonReaderTest.java @@ -0,0 +1,2424 @@ +/* + * Copyright (C) 2010 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.gitlab.jfronny.commons.serialize.json.test; + +import io.gitlab.jfronny.commons.serialize.json.JsonReader; +import io.gitlab.jfronny.commons.serialize.stream.MalformedDataException; +import io.gitlab.jfronny.commons.serialize.stream.Token; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.io.EOFException; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.Arrays; + +import static com.google.common.truth.Truth.assertThat; +import static io.gitlab.jfronny.commons.serialize.stream.Token.*; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; + +@SuppressWarnings("resource") +public final class JsonReaderTest { + /** + * Test for issue 212. + */ + @Test + public void testParseComments() throws IOException { + String json = "[\n" + + " // this is a comment\n" + + " \"a\",\n" + + " /* this is another comment */\n" + + " \"b\",\n" + + " # this is yet another comment\n" + + " \"c\"\n" + + "]"; + + JsonReader reader = new JsonReader(reader(json)).setLenient(true); + assertThat(reader.peek()).isEqualTo(BEGIN_ARRAY); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(STRING); + assertThat(reader.nextString()).isEqualTo("a"); + assertThat(reader.peek()).isEqualTo(STRING); + assertThat(reader.nextString()).isEqualTo("b"); + assertThat(reader.peek()).isEqualTo(STRING); + assertThat(reader.nextString()).isEqualTo("c"); + assertThat(reader.peek()).isEqualTo(END_ARRAY); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(END_DOCUMENT); + } + + @SuppressWarnings("deprecation") // for JsonReader.setLenient + @Test + public void testSetLenientTrue() { + JsonReader reader = new JsonReader(reader("{}")); + reader.setLenient(true); + assertThat(reader.isLenient()).isEqualTo(true); + } + + @SuppressWarnings("deprecation") // for JsonReader.setLenient + @Test + public void testSetLenientFalse() { + JsonReader reader = new JsonReader(reader("{}")); + reader.setLenient(false); + assertThat(reader.isLenient()).isEqualTo(false); + } + + @Test + public void testEscapedNewlineNotAllowedInStrictMode() throws IOException { + String json = "\"\\\n\""; + JsonReader reader = new JsonReader(reader(json)); + reader.setLenient(false); + + IOException expected = assertThrows(IOException.class, reader::nextString); + assertThat(expected) + .hasMessageThat() + .startsWith("Cannot escape a newline character in strict mode"); + } + + @Test + public void testEscapedNewlineAllowedInLenientMode() throws IOException { + String json = "\"\\\n\""; + JsonReader reader = new JsonReader(reader(json)).setLenient(true); + assertThat(reader.nextString()).isEqualTo("\n"); + } + + @Test + public void testStrictModeFailsToParseUnescapedControlCharacter() { + String json = "\"\0\""; + JsonReader reader = new JsonReader(reader(json)); + reader.setLenient(false); + + IOException expected = assertThrows(IOException.class, reader::nextString); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Unescaped control characters (\\u0000-\\u001F) are not allowed in strict mode"); + + json = "\"\t\""; + reader = new JsonReader(reader(json)); + reader.setLenient(false); + + expected = assertThrows(IOException.class, reader::nextString); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Unescaped control characters (\\u0000-\\u001F) are not allowed in strict mode"); + + json = "\"\u001F\""; + reader = new JsonReader(reader(json)); + reader.setLenient(false); + + expected = assertThrows(IOException.class, reader::nextString); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Unescaped control characters (\\u0000-\\u001F) are not allowed in strict mode"); + } + + @Test + public void testStrictModeAllowsOtherControlCharacters() throws IOException { + // JSON specification only forbids control characters U+0000 - U+001F, other control characters + // should be allowed + String json = "\"\u007F\u009F\""; + JsonReader reader = new JsonReader(reader(json)); + reader.setLenient(false); + assertThat(reader.nextString()).isEqualTo("\u007F\u009F"); + } + + @Test + public void testNonStrictModeParsesUnescapedControlCharacter() throws IOException { + String json = "\"\t\""; + JsonReader reader = new JsonReader(reader(json)).setLenient(true); + assertThat(reader.nextString()).isEqualTo("\t"); + } + + @Test + public void testCapitalizedTrueFailWhenStrict() throws IOException { + JsonReader reader = new JsonReader(reader("TRUE")); + reader.setLenient(false); + + IOException expected = assertThrows(IOException.class, reader::nextBoolean); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Use JsonReader.setLenient(true) to accept malformed JSON" + + " at line 1 column 1 (char 'T') path $"); + + reader = new JsonReader(reader("True")); + reader.setLenient(false); + + expected = assertThrows(IOException.class, reader::nextBoolean); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Use JsonReader.setLenient(true) to accept malformed JSON" + + " at line 1 column 1 (char 'T') path $"); + } + + @Test + public void testCapitalizedFalseFailWhenStrict() throws IOException { + JsonReader reader = new JsonReader(reader("FALSE")); + reader.setLenient(false); + + IOException expected = assertThrows(IOException.class, reader::nextBoolean); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Use JsonReader.setLenient(true) to accept malformed JSON" + + " at line 1 column 1 (char 'F') path $"); + + reader = new JsonReader(reader("FaLse")); + reader.setLenient(false); + + expected = assertThrows(IOException.class, reader::nextBoolean); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Use JsonReader.setLenient(true) to accept malformed JSON" + + " at line 1 column 1 (char 'F') path $"); + } + + @Test + public void testCapitalizedNullFailWhenStrict() throws IOException { + JsonReader reader = new JsonReader(reader("NULL")); + reader.setLenient(false); + + IOException expected = assertThrows(IOException.class, reader::nextNull); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Use JsonReader.setLenient(true) to accept malformed JSON" + + " at line 1 column 1 (char 'N') path $"); + + reader = new JsonReader(reader("nulL")); + reader.setLenient(false); + + expected = assertThrows(IOException.class, reader::nextNull); + assertThat(expected) + .hasMessageThat() + .startsWith( + "Use JsonReader.setLenient(true) to accept malformed JSON" + + " at line 1 column 1 (char 'n') path $"); + } + + @Test + public void testReadArray() throws IOException { + JsonReader reader = new JsonReader(reader("[true, true]")); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + assertThat(reader.nextBoolean()).isTrue(); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testReadEmptyArray() throws IOException { + JsonReader reader = new JsonReader(reader("[]")); + reader.beginArray(); + assertThat(reader.hasNext()).isFalse(); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testReadObject() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\": \"android\", \"b\": \"banana\"}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextString()).isEqualTo("android"); + assertThat(reader.nextName()).isEqualTo("b"); + assertThat(reader.nextString()).isEqualTo("banana"); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testReadEmptyObject() throws IOException { + JsonReader reader = new JsonReader(reader("{}")); + reader.beginObject(); + assertThat(reader.hasNext()).isFalse(); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testHasNextEndOfDocument() throws IOException { + JsonReader reader = new JsonReader(reader("{}")); + reader.beginObject(); + reader.endObject(); + assertThat(reader.hasNext()).isFalse(); + } + + @Test + public void testSkipArray() throws IOException { + JsonReader reader = + new JsonReader(reader("{\"a\": [\"one\", \"two\", \"three\"], \"b\": 123}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + reader.skipValue(); + assertThat(reader.nextName()).isEqualTo("b"); + assertThat(reader.nextInt()).isEqualTo(123); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testSkipArrayAfterPeek() throws Exception { + JsonReader reader = + new JsonReader(reader("{\"a\": [\"one\", \"two\", \"three\"], \"b\": 123}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.peek()).isEqualTo(BEGIN_ARRAY); + reader.skipValue(); + assertThat(reader.nextName()).isEqualTo("b"); + assertThat(reader.nextInt()).isEqualTo(123); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testSkipTopLevelObject() throws Exception { + JsonReader reader = + new JsonReader(reader("{\"a\": [\"one\", \"two\", \"three\"], \"b\": 123}")); + reader.skipValue(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testSkipObject() throws IOException { + JsonReader reader = + new JsonReader( + reader("{\"a\": { \"c\": [], \"d\": [true, true, {}] }, \"b\": \"banana\"}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + reader.skipValue(); + assertThat(reader.nextName()).isEqualTo("b"); + reader.skipValue(); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testSkipObjectAfterPeek() throws Exception { + String json = + "{" + + " \"one\": { \"num\": 1 }" + + ", \"two\": { \"num\": 2 }" + + ", \"three\": { \"num\": 3 }" + + "}"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("one"); + assertThat(reader.peek()).isEqualTo(BEGIN_OBJECT); + reader.skipValue(); + assertThat(reader.nextName()).isEqualTo("two"); + assertThat(reader.peek()).isEqualTo(BEGIN_OBJECT); + reader.skipValue(); + assertThat(reader.nextName()).isEqualTo("three"); + reader.skipValue(); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testSkipObjectName() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\": 1}")); + reader.beginObject(); + reader.skipValue(); + assertThat(reader.peek()).isEqualTo(Token.NUMBER); + assertThat(reader.getPath()).isEqualTo("$."); + assertThat(reader.nextInt()).isEqualTo(1); + } + + @Test + public void testSkipObjectNameSingleQuoted() throws IOException { + JsonReader reader = new JsonReader(reader("{'a': 1}")); + reader.setLenient(true); + reader.beginObject(); + reader.skipValue(); + assertThat(reader.peek()).isEqualTo(Token.NUMBER); + assertThat(reader.getPath()).isEqualTo("$."); + assertThat(reader.nextInt()).isEqualTo(1); + } + + @Test + public void testSkipObjectNameUnquoted() throws IOException { + JsonReader reader = new JsonReader(reader("{a: 1}")); + reader.setLenient(true); + reader.beginObject(); + reader.skipValue(); + assertThat(reader.peek()).isEqualTo(Token.NUMBER); + assertThat(reader.getPath()).isEqualTo("$."); + assertThat(reader.nextInt()).isEqualTo(1); + } + + @Test + public void testSkipInteger() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":123456789,\"b\":-123456789}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + reader.skipValue(); + assertThat(reader.nextName()).isEqualTo("b"); + reader.skipValue(); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testSkipDouble() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":-123.456e-789,\"b\":123456789.0}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + reader.skipValue(); + assertThat(reader.nextName()).isEqualTo("b"); + reader.skipValue(); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testSkipValueAfterEndOfDocument() throws IOException { + JsonReader reader = new JsonReader(reader("{}")); + reader.beginObject(); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + + assertThat(reader.getPath()).isEqualTo("$"); + assertThrows("Attempt to skip led outside the document", IllegalStateException.class, reader::skipValue); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void testSkipValueAtArrayEnd() throws IOException { + JsonReader reader = new JsonReader(reader("[]")); + reader.beginArray(); + assertThrows("Attempt to skip led outside the document", IllegalStateException.class, reader::skipValue); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void testSkipValueAtObjectEnd() throws IOException { + JsonReader reader = new JsonReader(reader("{}")); + reader.beginObject(); + assertThrows("Attempt to skip led outside the document", IllegalStateException.class, reader::skipValue); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + assertThat(reader.getPath()).isEqualTo("$"); + } + + @Test + public void testHelloWorld() throws IOException { + String json = + "{\n" // + + " \"hello\": true,\n" // + + " \"foo\": [\"world\"]\n" // + + "}"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("hello"); + assertThat(reader.nextBoolean()).isTrue(); + assertThat(reader.nextName()).isEqualTo("foo"); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo("world"); + reader.endArray(); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testInvalidJsonInput() throws IOException { + String json = + "{\n" // + + " \"h\\ello\": true,\n" // + + " \"foo\": [\"world\"]\n" // + + "}"; + + JsonReader reader = new JsonReader(reader(json)); + reader.beginObject(); + try { + reader.nextName(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Invalid escape sequence at line 2 column 8 (char 'l') path $."); + } + } + + @SuppressWarnings("unused") + @Test + public void testNulls() { + try { + new JsonReader(null); + fail(); + } catch (NullPointerException expected) { + } + } + + @Test + public void testEmptyString() throws IOException { + try { + new JsonReader(reader("")).beginArray(); + fail(); + } catch (EOFException expected) { + } + try { + new JsonReader(reader("")).beginObject(); + fail(); + } catch (EOFException expected) { + } + } + + @Test + public void testCharacterUnescaping() throws IOException { + String json = + "[\"a\"," + + "\"a\\\"\"," + + "\"\\\"\"," + + "\":\"," + + "\",\"," + + "\"\\b\"," + + "\"\\f\"," + + "\"\\n\"," + + "\"\\r\"," + + "\"\\t\"," + + "\" \"," + + "\"\\\\\"," + + "\"{\"," + + "\"}\"," + + "\"[\"," + + "\"]\"," + + "\"\\u0000\"," + + "\"\\u0019\"," + + "\"\\u20AC\"" + + "]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo("a"); + assertThat(reader.nextString()).isEqualTo("a\""); + assertThat(reader.nextString()).isEqualTo("\""); + assertThat(reader.nextString()).isEqualTo(":"); + assertThat(reader.nextString()).isEqualTo(","); + assertThat(reader.nextString()).isEqualTo("\b"); + assertThat(reader.nextString()).isEqualTo("\f"); + assertThat(reader.nextString()).isEqualTo("\n"); + assertThat(reader.nextString()).isEqualTo("\r"); + assertThat(reader.nextString()).isEqualTo("\t"); + assertThat(reader.nextString()).isEqualTo(" "); + assertThat(reader.nextString()).isEqualTo("\\"); + assertThat(reader.nextString()).isEqualTo("{"); + assertThat(reader.nextString()).isEqualTo("}"); + assertThat(reader.nextString()).isEqualTo("["); + assertThat(reader.nextString()).isEqualTo("]"); + assertThat(reader.nextString()).isEqualTo("\0"); + assertThat(reader.nextString()).isEqualTo("\u0019"); + assertThat(reader.nextString()).isEqualTo("\u20AC"); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testReaderDoesNotTreatU2028U2029AsNewline() throws IOException { + // This test shows that the JSON string [\n"whatever"] is seen as valid + // And the JSON string [\u2028"whatever"] is not. + String jsonInvalid2028 = "[\u2028\"whatever\"]"; + JsonReader readerInvalid2028 = new JsonReader(reader(jsonInvalid2028)); + readerInvalid2028.beginArray(); + assertThrows(IOException.class, readerInvalid2028::nextString); + + String jsonInvalid2029 = "[\u2029\"whatever\"]"; + JsonReader readerInvalid2029 = new JsonReader(reader(jsonInvalid2029)); + readerInvalid2029.beginArray(); + assertThrows(IOException.class, readerInvalid2029::nextString); + + String jsonValid = "[\n\"whatever\"]"; + JsonReader readerValid = new JsonReader(reader(jsonValid)); + readerValid.beginArray(); + assertThat(readerValid.nextString()).isEqualTo("whatever"); + + // And even in STRICT mode U+2028 and U+2029 are not considered control characters + // and can appear unescaped in JSON string + String jsonValid2028And2029 = "\"whatever\u2028\u2029\""; + JsonReader readerValid2028And2029 = new JsonReader(reader(jsonValid2028And2029)); + readerValid2028And2029.setLenient(false); + assertThat(readerValid2028And2029.nextString()).isEqualTo("whatever\u2028\u2029"); + } + + @Test + public void testEscapeCharacterQuoteInStrictMode() throws IOException { + String json = "\"\\'\""; + JsonReader reader = new JsonReader(reader(json)); + reader.setLenient(false); + + IOException expected = assertThrows(IOException.class, reader::nextString); + assertThat(expected) + .hasMessageThat() + .startsWith("Invalid escaped character \"'\" in strict mode"); + } + + @Test + public void testEscapeCharacterQuoteWithoutStrictMode() throws IOException { + String json = "\"\\'\""; + JsonReader reader = new JsonReader(reader(json)).setLenient(true); + assertThat(reader.nextString()).isEqualTo("'"); + } + + @Test + public void testUnescapingInvalidCharacters() throws IOException { + String json = "[\"\\u000g\"]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + try { + reader.nextString(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Malformed Unicode escape \\u000g at line 1 column 5 (char '0') path $[0]"); + } + } + + @Test + public void testUnescapingTruncatedCharacters() throws IOException { + String json = "[\"\\u000"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + try { + reader.nextString(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Unterminated escape sequence at line 1 column 5 (char '0') path $[0]"); + } + } + + @Test + public void testUnescapingTruncatedSequence() throws IOException { + String json = "[\"\\"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + try { + reader.nextString(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Unterminated escape sequence at line 1 column 4 (char '[') path $[0]"); + } + } + + @Test + public void testIntegersWithFractionalPartSpecified() throws IOException { + JsonReader reader = new JsonReader(reader("[1.0,1.0,1.0]")); + reader.beginArray(); + assertThat(reader.nextDouble()).isEqualTo(1.0); + assertThat(reader.nextInt()).isEqualTo(1); + assertThat(reader.nextLong()).isEqualTo(1L); + } + + @Test + public void testDoubles() throws IOException { + String json = + "[-0.0," + + "1.0," + + "1.7976931348623157E308," + + "4.9E-324," + + "0.0," + + "0.00," + + "-0.5," + + "2.2250738585072014E-308," + + "3.141592653589793," + + "2.718281828459045," + + "0," + + "0.01," + + "0e0," + + "1e+0," + + "1e-0," + + "1e0000," // leading 0 is allowed for exponent + + "1e00001," + + "1e+1]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + assertThat(reader.nextDouble()).isEqualTo(-0.0); + assertThat(reader.nextDouble()).isEqualTo(1.0); + assertThat(reader.nextDouble()).isEqualTo(1.7976931348623157E308); + assertThat(reader.nextDouble()).isEqualTo(4.9E-324); + assertThat(reader.nextDouble()).isEqualTo(0.0); + assertThat(reader.nextDouble()).isEqualTo(0.0); + assertThat(reader.nextDouble()).isEqualTo(-0.5); + assertThat(reader.nextDouble()).isEqualTo(2.2250738585072014E-308); + assertThat(reader.nextDouble()).isEqualTo(3.141592653589793); + assertThat(reader.nextDouble()).isEqualTo(2.718281828459045); + assertThat(reader.nextDouble()).isEqualTo(0.0); + assertThat(reader.nextDouble()).isEqualTo(0.01); + assertThat(reader.nextDouble()).isEqualTo(0.0); + assertThat(reader.nextDouble()).isEqualTo(1.0); + assertThat(reader.nextDouble()).isEqualTo(1.0); + assertThat(reader.nextDouble()).isEqualTo(1.0); + assertThat(reader.nextDouble()).isEqualTo(10.0); + assertThat(reader.nextDouble()).isEqualTo(10.0); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testStrictNonFiniteDoubles() throws IOException { + String json = "[NaN]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + try { + reader.nextDouble(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char 'N') path $[0]"); + } + } + + @Test + public void testStrictQuotedNonFiniteDoubles() throws IOException { + String json = "[\"NaN\"]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + try { + reader.nextDouble(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "JSON forbids NaN and infinities: NaN at line 1 column 7 (char ']') path $[0]"); + } + } + + @Test + public void testLenientNonFiniteDoubles() throws IOException { + String json = "[NaN, -Infinity, Infinity]"; + JsonReader reader = new JsonReader(reader(json)); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextDouble()).isNaN(); + assertThat(reader.nextDouble()).isEqualTo(Double.NEGATIVE_INFINITY); + assertThat(reader.nextDouble()).isEqualTo(Double.POSITIVE_INFINITY); + reader.endArray(); + } + + @Test + public void testLenientQuotedNonFiniteDoubles() throws IOException { + String json = "[\"NaN\", \"-Infinity\", \"Infinity\"]"; + JsonReader reader = new JsonReader(reader(json)); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextDouble()).isNaN(); + assertThat(reader.nextDouble()).isEqualTo(Double.NEGATIVE_INFINITY); + assertThat(reader.nextDouble()).isEqualTo(Double.POSITIVE_INFINITY); + reader.endArray(); + } + + @Test + public void testStrictNonFiniteDoublesWithSkipValue() throws IOException { + String json = "[NaN]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char 'N') path $[0]"); + } + } + + @Test + public void testLongs() throws IOException { + String json = + "[0,0,0," + "1,1,1," + "-1,-1,-1," + "-9223372036854775808," + "9223372036854775807]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + assertThat(reader.nextLong()).isEqualTo(0L); + assertThat(reader.nextInt()).isEqualTo(0); + assertThat(reader.nextDouble()).isEqualTo(0.0); + assertThat(reader.nextLong()).isEqualTo(1L); + assertThat(reader.nextInt()).isEqualTo(1); + assertThat(reader.nextDouble()).isEqualTo(1.0); + assertThat(reader.nextLong()).isEqualTo(-1L); + assertThat(reader.nextInt()).isEqualTo(-1); + assertThat(reader.nextDouble()).isEqualTo(-1.0); + try { + reader.nextInt(); + fail(); + } catch (NumberFormatException expected) { + } + assertThat(reader.nextLong()).isEqualTo(Long.MIN_VALUE); + try { + reader.nextInt(); + fail(); + } catch (NumberFormatException expected) { + } + assertThat(reader.nextLong()).isEqualTo(Long.MAX_VALUE); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + @Disabled( + "JsonReader advances after exception for invalid number was thrown; to be decided if that is" + + " acceptable") + public void testNumberWithOctalPrefix() throws IOException { + String json = "[01]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + try { + reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char '0') path $[0]"); + } + try { + reader.nextInt(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "TODO"); + } + try { + reader.nextLong(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "TODO"); + } + try { + reader.nextDouble(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "TODO"); + } + assertThat(reader.nextString()).isEqualTo("01"); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testBooleans() throws IOException { + JsonReader reader = new JsonReader(reader("[true,false]")); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + assertThat(reader.nextBoolean()).isFalse(); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testPeekingUnquotedStringsPrefixedWithBooleans() throws IOException { + JsonReader reader = new JsonReader(reader("[truey]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(STRING); + try { + reader.nextBoolean(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError(expected, "a boolean", "STRING", "line 1 column 2 (char 't') path $[0]"); + } + assertThat(reader.nextString()).isEqualTo("truey"); + reader.endArray(); + } + + @Test + public void testMalformedNumbers() throws IOException { + assertNotANumber("-"); + assertNotANumber("."); + + // plus sign is not allowed for integer part + assertNotANumber("+1"); + + // leading 0 is not allowed for integer part + assertNotANumber("00"); + assertNotANumber("01"); + + // exponent lacks digit + assertNotANumber("e"); + assertNotANumber("0e"); + assertNotANumber(".e"); + assertNotANumber("0.e"); + assertNotANumber("-.0e"); + + // no integer + assertNotANumber("e1"); + assertNotANumber(".e1"); + assertNotANumber("-e1"); + + // trailing characters + assertNotANumber("1x"); + assertNotANumber("1.1x"); + assertNotANumber("1e1x"); + assertNotANumber("1ex"); + assertNotANumber("1.1ex"); + assertNotANumber("1.1e1x"); + + // fraction has no digit + assertNotANumber("0."); + assertNotANumber("-0."); + assertNotANumber("0.e1"); + assertNotANumber("-0.e1"); + + // no leading digit + assertNotANumber(".0"); + assertNotANumber("-.0"); + assertNotANumber(".0e1"); + assertNotANumber("-.0e1"); + } + + private static void assertNotANumber(String s) throws IOException { + JsonReader reader = new JsonReader(reader(s)); + reader.setLenient(true); + assertThat(reader.peek()).isEqualTo(Token.STRING); + assertThat(reader.nextString()).isEqualTo(s); + + JsonReader strictReader = new JsonReader(reader(s)); + try { + strictReader.nextDouble(); + fail("Should have failed reading " + s + " as double"); + } catch (MalformedDataException e) { + assertThat(e) + .hasMessageThat() + .startsWith("Use JsonReader.setLenient(true) to accept malformed JSON"); + } + } + + @Test + public void testPeekingUnquotedStringsPrefixedWithIntegers() throws IOException { + JsonReader reader = new JsonReader(reader("[12.34e5x]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(STRING); + try { + reader.nextInt(); + fail(); + } catch (NumberFormatException expected) { + } + assertThat(reader.nextString()).isEqualTo("12.34e5x"); + } + + @Test + public void testPeekLongMinValue() throws IOException { + JsonReader reader = new JsonReader(reader("[-9223372036854775808]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(NUMBER); + assertThat(reader.nextLong()).isEqualTo(-9223372036854775808L); + } + + @Test + public void testPeekLongMaxValue() throws IOException { + JsonReader reader = new JsonReader(reader("[9223372036854775807]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(NUMBER); + assertThat(reader.nextLong()).isEqualTo(9223372036854775807L); + } + + @Test + public void testLongLargerThanMaxLongThatWrapsAround() throws IOException { + JsonReader reader = new JsonReader(reader("[22233720368547758070]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(NUMBER); + try { + reader.nextLong(); + fail(); + } catch (NumberFormatException expected) { + } + } + + @Test + public void testLongLargerThanMinLongThatWrapsAround() throws IOException { + JsonReader reader = new JsonReader(reader("[-22233720368547758070]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(NUMBER); + try { + reader.nextLong(); + fail(); + } catch (NumberFormatException expected) { + } + } + + /** Issue 1053, negative zero. */ + @Test + public void testNegativeZero() throws Exception { + JsonReader reader = new JsonReader(reader("[-0]")); + reader.setLenient(false); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(NUMBER); + assertThat(reader.nextString()).isEqualTo("-0"); + } + + /** + * This test fails because there's no double for 9223372036854775808, and our long parsing uses + * Double.parseDouble() for fractional values. + */ + @Test + @Disabled + public void testPeekLargerThanLongMaxValue() throws IOException { + JsonReader reader = new JsonReader(reader("[9223372036854775808]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(NUMBER); + try { + reader.nextLong(); + fail(); + } catch (NumberFormatException e) { + } + } + + /** + * This test fails because there's no double for -9223372036854775809, and our long parsing uses + * Double.parseDouble() for fractional values. + */ + @Test + @Disabled + public void testPeekLargerThanLongMinValue() throws IOException { + @SuppressWarnings("FloatingPointLiteralPrecision") + double d = -9223372036854775809d; + JsonReader reader = new JsonReader(reader("[-9223372036854775809]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(NUMBER); + try { + reader.nextLong(); + fail(); + } catch (NumberFormatException expected) { + } + assertThat(reader.nextDouble()).isEqualTo(d); + } + + /** + * This test fails because there's no double for 9223372036854775806, and our long parsing uses + * Double.parseDouble() for fractional values. + */ + @Test + @Disabled + public void testHighPrecisionLong() throws IOException { + String json = "[9223372036854775806.000]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + assertThat(reader.nextLong()).isEqualTo(9223372036854775806L); + reader.endArray(); + } + + @Test + public void testPeekMuchLargerThanLongMinValue() throws IOException { + @SuppressWarnings("FloatingPointLiteralPrecision") + double d = -92233720368547758080d; + JsonReader reader = new JsonReader(reader("[-92233720368547758080]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(NUMBER); + try { + reader.nextLong(); + fail(); + } catch (NumberFormatException expected) { + } + assertThat(reader.nextDouble()).isEqualTo(d); + } + + @Test + public void testQuotedNumberWithEscape() throws IOException { + JsonReader reader = new JsonReader(reader("[\"12\\u00334\"]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(STRING); + assertThat(reader.nextInt()).isEqualTo(1234); + } + + @Test + public void testMixedCaseLiterals() throws IOException { + JsonReader reader = new JsonReader(reader("[True,TruE,False,FALSE,NULL,nulL]")).setLenient(true); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + assertThat(reader.nextBoolean()).isTrue(); + assertThat(reader.nextBoolean()).isFalse(); + assertThat(reader.nextBoolean()).isFalse(); + reader.nextNull(); + reader.nextNull(); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testMissingValue() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + try { + reader.nextString(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Expected value at line 1 column 6 (char '}') path $.a"); + } + } + + @Test + public void testPrematureEndOfInput() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":true,")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextBoolean()).isTrue(); + try { + reader.nextName(); + fail(); + } catch (EOFException expected) { + } + } + + @Test + public void testPrematurelyClosed() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":[]}")); + reader.beginObject(); + reader.close(); + try { + reader.nextName(); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("JsonReader is closed"); + } + + reader = new JsonReader(reader("{\"a\":[]}")); + reader.close(); + try { + reader.beginObject(); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("JsonReader is closed"); + } + + reader = new JsonReader(reader("{\"a\":true}")); + reader.beginObject(); + String unused1 = reader.nextName(); + Token unused2 = reader.peek(); + reader.close(); + try { + reader.nextBoolean(); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("JsonReader is closed"); + } + } + + @Test + public void testNextFailuresDoNotAdvance() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":true}")); + reader.beginObject(); + try { + String unused = reader.nextString(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError(expected, "a string", "NAME", "line 1 column 3 (char 'a') path $."); + } + assertThat(reader.nextName()).isEqualTo("a"); + try { + String unused = reader.nextName(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError(expected, "a name", "BOOLEAN", "line 1 column 10 (char '}') path $.a"); + } + try { + reader.beginArray(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError( + expected, "BEGIN_ARRAY", "BOOLEAN", "line 1 column 10 (char '}') path $.a"); + } + try { + reader.endArray(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError(expected, "END_ARRAY", "BOOLEAN", "line 1 column 10 (char '}') path $.a"); + } + try { + reader.beginObject(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError( + expected, "BEGIN_OBJECT", "BOOLEAN", "line 1 column 10 (char '}') path $.a"); + } + try { + reader.endObject(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError( + expected, "END_OBJECT", "BOOLEAN", "line 1 column 10 (char '}') path $.a"); + } + assertThat(reader.nextBoolean()).isTrue(); + try { + reader.nextString(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError( + expected, "a string", "END_OBJECT", "line 1 column 11 (char '\\0') path $.a"); + } + try { + reader.nextName(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError(expected, "a name", "END_OBJECT", "line 1 column 11 (char '\\0') path $.a"); + } + try { + reader.beginArray(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError( + expected, "BEGIN_ARRAY", "END_OBJECT", "line 1 column 11 (char '\\0') path $.a"); + } + try { + reader.endArray(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError( + expected, "END_ARRAY", "END_OBJECT", "line 1 column 11 (char '\\0') path $.a"); + } + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + reader.close(); + } + + @Test + public void testIntegerMismatchFailuresDoNotAdvance() throws IOException { + JsonReader reader = new JsonReader(reader("[1.5]")); + reader.beginArray(); + try { + reader.nextInt(); + fail(); + } catch (NumberFormatException expected) { + } + assertThat(reader.nextDouble()).isEqualTo(1.5d); + reader.endArray(); + } + + @Test + public void testStringNullIsNotNull() throws IOException { + JsonReader reader = new JsonReader(reader("[\"null\"]")); + reader.beginArray(); + try { + reader.nextNull(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError(expected, "null", "STRING", "line 1 column 3 (char 'n') path $[0]"); + } + } + + @Test + public void testNullLiteralIsNotAString() throws IOException { + JsonReader reader = new JsonReader(reader("[null]")); + reader.beginArray(); + try { + reader.nextString(); + fail(); + } catch (IllegalStateException expected) { + assertUnexpectedStructureError(expected, "a string", "NULL", "line 1 column 6 (char ']') path $[0]"); + } + } + + @Test + public void testStrictNameValueSeparator() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\"=true}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + try { + reader.nextBoolean(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 6 (char 't') path $.a"); + } + + reader = new JsonReader(reader("{\"a\"=>true}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + try { + reader.nextBoolean(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 6 (char '>') path $.a"); + } + } + + @Test + public void testLenientNameValueSeparator() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\"=true}")); + reader.setLenient(true); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextBoolean()).isTrue(); + + reader = new JsonReader(reader("{\"a\"=>true}")); + reader.setLenient(true); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextBoolean()).isTrue(); + } + + @Test + public void testStrictNameValueSeparatorWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\"=true}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 6 (char 't') path $.a"); + } + + reader = new JsonReader(reader("{\"a\"=>true}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 6 (char '>') path $.a"); + } + } + + @Test + public void testCommentsInStringValue() throws Exception { + JsonReader reader = new JsonReader(reader("[\"// comment\"]")); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo("// comment"); + reader.endArray(); + + reader = new JsonReader(reader("{\"a\":\"#someComment\"}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextString()).isEqualTo("#someComment"); + reader.endObject(); + + reader = new JsonReader(reader("{\"#//a\":\"#some //Comment\"}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("#//a"); + assertThat(reader.nextString()).isEqualTo("#some //Comment"); + reader.endObject(); + } + + @Test + public void testStrictComments() throws IOException { + JsonReader reader = new JsonReader(reader("[// comment \n true]")); + reader.beginArray(); + try { + reader.nextBoolean(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char '/') path $[0]"); + } + + reader = new JsonReader(reader("[# comment \n true]")); + reader.beginArray(); + try { + reader.nextBoolean(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char ' ') path $[0]"); + } + + reader = new JsonReader(reader("[/* comment */ true]")); + reader.beginArray(); + try { + reader.nextBoolean(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char '*') path $[0]"); + } + } + + @Test + public void testLenientComments() throws IOException { + JsonReader reader = new JsonReader(reader("[// comment \n true]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + + reader = new JsonReader(reader("[# comment \n true]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + + reader = new JsonReader(reader("[/* comment */ true]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + } + + @Test + public void testStrictCommentsWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("[// comment \n true]")); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char '/') path $[0]"); + } + + reader = new JsonReader(reader("[# comment \n true]")); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char ' ') path $[0]"); + } + + reader = new JsonReader(reader("[/* comment */ true]")); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char '*') path $[0]"); + } + } + + @Test + public void testStrictUnquotedNames() throws IOException { + JsonReader reader = new JsonReader(reader("{a:true}")); + reader.beginObject(); + try { + reader.nextName(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char ':') path $."); + } + } + + @Test + public void testLenientUnquotedNames() throws IOException { + JsonReader reader = new JsonReader(reader("{a:true}")); + reader.setLenient(true); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + } + + @Test + public void testStrictUnquotedNamesWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("{a:true}")); + reader.beginObject(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char ':') path $."); + } + } + + @Test + public void testStrictSingleQuotedNames() throws IOException { + JsonReader reader = new JsonReader(reader("{'a':true}")); + reader.beginObject(); + try { + reader.nextName(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char 'a') path $."); + } + } + + @Test + public void testLenientSingleQuotedNames() throws IOException { + JsonReader reader = new JsonReader(reader("{'a':true}")); + reader.setLenient(true); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + } + + @Test + public void testStrictSingleQuotedNamesWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("{'a':true}")); + reader.beginObject(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char 'a') path $."); + } + } + + @Test + public void testStrictUnquotedStrings() throws IOException { + JsonReader reader = new JsonReader(reader("[a]")); + reader.beginArray(); + try { + reader.nextString(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char 'a') path $[0]"); + } + } + + @Test + public void testStrictUnquotedStringsWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("[a]")); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char 'a') path $[0]"); + } + } + + @Test + public void testLenientUnquotedStrings() throws IOException { + JsonReader reader = new JsonReader(reader("[a]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo("a"); + } + + @Test + public void testStrictSingleQuotedStrings() throws IOException { + JsonReader reader = new JsonReader(reader("['a']")); + reader.beginArray(); + try { + reader.nextString(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char 'a') path $[0]"); + } + } + + @Test + public void testLenientSingleQuotedStrings() throws IOException { + JsonReader reader = new JsonReader(reader("['a']")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo("a"); + } + + @Test + public void testStrictSingleQuotedStringsWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("['a']")); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char 'a') path $[0]"); + } + } + + @Test + public void testStrictSemicolonDelimitedArray() throws IOException { + JsonReader reader = new JsonReader(reader("[true;true]")); + reader.beginArray(); + try { + boolean unused = reader.nextBoolean(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char 't') path $[0]"); + } + } + + @Test + public void testLenientSemicolonDelimitedArray() throws IOException { + JsonReader reader = new JsonReader(reader("[true;true]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + assertThat(reader.nextBoolean()).isTrue(); + } + + @Test + public void testStrictSemicolonDelimitedArrayWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("[true;true]")); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char 't') path $[0]"); + } + } + + @Test + public void testStrictSemicolonDelimitedNameValuePair() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":true;\"b\":true}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + try { + boolean unused = reader.nextBoolean(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 6 (char 't') path $.a"); + } + } + + @Test + public void testLenientSemicolonDelimitedNameValuePair() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":true;\"b\":true}")); + reader.setLenient(true); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextBoolean()).isTrue(); + assertThat(reader.nextName()).isEqualTo("b"); + } + + @Test + public void testStrictSemicolonDelimitedNameValuePairWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":true;\"b\":true}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 6 (char 't') path $.a"); + } + } + + @Test + public void testStrictUnnecessaryArraySeparators() throws IOException { + JsonReader reader = new JsonReader(reader("[true,,true]")); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + try { + reader.nextNull(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 8 (char 't') path $[1]"); + } + + reader = new JsonReader(reader("[,true]")); + reader.beginArray(); + try { + reader.nextNull(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char 't') path $[0]"); + } + + reader = new JsonReader(reader("[true,]")); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + try { + reader.nextNull(); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().startsWith("Expected null but was END_ARRAY at line 1 column 8 (char '\\0') path $[1]"); + } + + reader = new JsonReader(reader("[,]")); + reader.beginArray(); + try { + reader.nextNull(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char ']') path $[0]"); + } + } + + @Test + public void testLenientUnnecessaryArraySeparators() throws IOException { + JsonReader reader = new JsonReader(reader("[true,,true]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); +// reader.nextNull(); + assertThat(reader.nextBoolean()).isTrue(); + reader.endArray(); + + reader = new JsonReader(reader("[,true]")); + reader.setLenient(true); + reader.beginArray(); +// reader.nextNull(); + assertThat(reader.nextBoolean()).isTrue(); + reader.endArray(); + + reader = new JsonReader(reader("[true,]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); +// reader.nextNull(); + reader.endArray(); + + reader = new JsonReader(reader("[,]")); + reader.setLenient(true); + reader.beginArray(); + //reader.nextNull(); + //reader.nextNull(); + reader.endArray(); + } + + @Test + public void testStrictUnnecessaryArraySeparatorsWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("[true,,true]")); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 8 (char 't') path $[1]"); + } + + reader = new JsonReader(reader("[,true]")); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char 't') path $[0]"); + } + + reader = new JsonReader(reader("[true,]")); + reader.beginArray(); + assertThat(reader.nextBoolean()).isTrue(); + try { + reader.skipValue(); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Attempt to skip led outside its parent"); + } + + reader = new JsonReader(reader("[,]")); + reader.beginArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 3 (char ']') path $[0]"); + } + } + + @Test + public void testStrictMultipleTopLevelValues() throws IOException { + JsonReader reader = new JsonReader(reader("[] []")); + reader.beginArray(); + reader.endArray(); + try { + reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 5 (char ']') path $"); + } + } + + @Test + public void testLenientMultipleTopLevelValues() throws IOException { + JsonReader reader = new JsonReader(reader("[] true {}")); + reader.setLenient(true); + reader.beginArray(); + reader.endArray(); + assertThat(reader.nextBoolean()).isTrue(); + reader.beginObject(); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testStrictMultipleTopLevelValuesWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("[] []")); + reader.beginArray(); + reader.endArray(); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 5 (char ']') path $"); + } + } + + @Test + public void testTopLevelValueTypes() throws IOException { + JsonReader reader1 = new JsonReader(reader("true")); + assertThat(reader1.nextBoolean()).isTrue(); + assertThat(reader1.peek()).isEqualTo(Token.END_DOCUMENT); + + JsonReader reader2 = new JsonReader(reader("false")); + assertThat(reader2.nextBoolean()).isFalse(); + assertThat(reader2.peek()).isEqualTo(Token.END_DOCUMENT); + + JsonReader reader3 = new JsonReader(reader("null")); + assertThat(reader3.peek()).isEqualTo(Token.NULL); + reader3.nextNull(); + assertThat(reader3.peek()).isEqualTo(Token.END_DOCUMENT); + + JsonReader reader4 = new JsonReader(reader("123")); + assertThat(reader4.nextInt()).isEqualTo(123); + assertThat(reader4.peek()).isEqualTo(Token.END_DOCUMENT); + + JsonReader reader5 = new JsonReader(reader("123.4")); + assertThat(reader5.nextDouble()).isEqualTo(123.4); + assertThat(reader5.peek()).isEqualTo(Token.END_DOCUMENT); + + JsonReader reader6 = new JsonReader(reader("\"a\"")); + assertThat(reader6.nextString()).isEqualTo("a"); + assertThat(reader6.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testTopLevelValueTypeWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader("true")); + reader.skipValue(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testStrictNonExecutePrefix() throws IOException { + JsonReader reader = new JsonReader(reader(")]}'\n []")); + try { + reader.beginArray(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 1 (char ')') path $"); + } + } + + @Test + public void testStrictNonExecutePrefixWithSkipValue() throws IOException { + JsonReader reader = new JsonReader(reader(")]}'\n []")); + try { + reader.skipValue(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 1 (char ')') path $"); + } + } + + @Test + public void testLenientNonExecutePrefix() throws IOException { + JsonReader reader = new JsonReader(reader(")]}'\n []")); + reader.setLenient(true); + reader.beginArray(); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testLenientNonExecutePrefixWithLeadingWhitespace() throws IOException { + JsonReader reader = new JsonReader(reader("\r\n \t)]}'\n []")); + reader.setLenient(true); + reader.beginArray(); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testLenientPartialNonExecutePrefix() throws IOException { + JsonReader reader = new JsonReader(reader(")]}' []")); + reader.setLenient(true); + assertThat(reader.nextString()).isEqualTo(")"); + try { + reader.nextString(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Unexpected value at line 1 column 3 (char '}') path $"); + } + } + + @Test + public void testBomIgnoredAsFirstCharacterOfDocument() throws IOException { + JsonReader reader = new JsonReader(reader("\ufeff[]")); + reader.beginArray(); + reader.endArray(); + } + + @Test + public void testBomForbiddenAsOtherCharacterInDocument() throws IOException { + JsonReader reader = new JsonReader(reader("[\ufeff]")); + reader.beginArray(); + try { + reader.endArray(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char '\uFEFF') path $[0]"); + } + } + + @SuppressWarnings("UngroupedOverloads") + @Test + public void testFailWithPosition() throws IOException { + testFailWithPosition("Expected value at line 6 column 5 (char '}') path $[1]", "[\n\n\n\n\n\"a\",}]"); + } + + @Test + public void testFailWithPositionGreaterThanBufferSize() throws IOException { + String spaces = repeat(' ', 8192); + testFailWithPosition( + "Expected value at line 6 column 5 (char '}') path $[1]", "[\n\n" + spaces + "\n\n\n\"a\",}]"); + } + + @Test + public void testFailWithPositionOverSlashSlashEndOfLineComment() throws IOException { + testFailWithPosition( + "Expected value at line 5 column 6 (char '}') path $[1]", "\n// foo\n\n//bar\r\n[\"a\",}"); + } + + @Test + public void testFailWithPositionOverHashEndOfLineComment() throws IOException { + testFailWithPosition( + "Expected value at line 5 column 6 (char '}') path $[1]", "\n# foo\n\n#bar\r\n[\"a\",}"); + } + + @Test + public void testFailWithPositionOverCStyleComment() throws IOException { + testFailWithPosition( + "Expected value at line 6 column 12 (char '}') path $[1]", "\n\n/* foo\n*\n*\r\nbar */[\"a\",}"); + } + + @Test + public void testFailWithPositionOverQuotedString() throws IOException { + testFailWithPosition( + "Expected value at line 5 column 3 (char '}') path $[1]", "[\"foo\nbar\r\nbaz\n\",\n }"); + } + + @Test + public void testFailWithPositionOverUnquotedString() throws IOException { + testFailWithPosition("Expected value at line 5 column 2 (char '}') path $[1]", "[\n\nabcd\n\n,}"); + } + + @Test + public void testFailWithEscapedNewlineCharacter() throws IOException { + testFailWithPosition("Expected value at line 5 column 3 (char '}') path $[1]", "[\n\n\"\\\n\n\",}"); + } + + @Test + public void testFailWithPositionIsOffsetByBom() throws IOException { + testFailWithPosition("Expected value at line 1 column 6 (char '}') path $[1]", "\ufeff[\"a\",}]"); + } + + private static void testFailWithPosition(String message, String json) throws IOException { + // Validate that it works reading the string normally. + JsonReader reader1 = new JsonReader(reader(json)); + reader1.setLenient(true); + reader1.beginArray(); + String unused1 = reader1.nextString(); + try { + Token unused2 = reader1.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo(message); + } + + // Also validate that it works when skipping. + JsonReader reader2 = new JsonReader(reader(json)); + reader2.setLenient(true); + reader2.beginArray(); + reader2.skipValue(); + try { + Token unused3 = reader2.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo(message); + } + } + + @Test + public void testFailWithPositionDeepPath() throws IOException { + JsonReader reader = new JsonReader(reader("[1,{\"a\":[2,3,}")); + reader.beginArray(); + int unused1 = reader.nextInt(); + reader.beginObject(); + String unused2 = reader.nextName(); + reader.beginArray(); + int unused3 = reader.nextInt(); + int unused4 = reader.nextInt(); + try { + Token unused5 = reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Expected value at line 1 column 14 (char '}') path $[1].a[2]"); + } + } + + @Test + public void testStrictVeryLongNumber() throws IOException { + JsonReader reader = new JsonReader(reader("[0." + repeat('9', 8192) + "]")).setLenient(false); + reader.beginArray(); + try { + reader.nextDouble(); + fail(); + } catch (MalformedDataException expected) { + assertStrictError(expected, "line 1 column 2 (char '0') path $[0]"); + } + } + + @Test + public void testLenientVeryLongNumber() throws IOException { + JsonReader reader = new JsonReader(reader("[0." + repeat('9', 8192) + "]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(Token.STRING); + assertThat(reader.nextDouble()).isEqualTo(1d); + reader.endArray(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testVeryLongUnquotedLiteral() throws IOException { + String literal = "a" + repeat('b', 8192) + "c"; + JsonReader reader = new JsonReader(reader("[" + literal + "]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo(literal); + reader.endArray(); + } + + @Test + public void testDeeplyNestedArrays() throws IOException { + // this is nested 40 levels deep; Gson is tuned for nesting is 30 levels deep or fewer + JsonReader reader = + new JsonReader( + reader( + "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]")); + for (int i = 0; i < 40; i++) { + reader.beginArray(); + } + assertThat(reader.getPath()) + .isEqualTo( + "$[0][0][0][0][0][0][0][0][0][0][0][0][0][0][0]" + + "[0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0]"); + for (int i = 0; i < 40; i++) { + reader.endArray(); + } + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testDeeplyNestedObjects() throws IOException { + // Build a JSON document structured like {"a":{"a":{"a":{"a":true}}}}, but 40 levels deep + String array = "{\"a\":%s}"; + String json = "true"; + for (int i = 0; i < 40; i++) { + json = String.format(array, json); + } + + JsonReader reader = new JsonReader(reader(json)); + for (int i = 0; i < 40; i++) { + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + } + assertThat(reader.getPath()) + .isEqualTo( + "$.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" + + ".a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a"); + assertThat(reader.nextBoolean()).isTrue(); + for (int i = 0; i < 40; i++) { + reader.endObject(); + } + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + // http://code.google.com/p/google-gson/issues/detail?id=409 + @Test + public void testStringEndingInSlash() throws IOException { + JsonReader reader = new JsonReader(reader("/")); + reader.setLenient(true); + try { + reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Expected value at line 1 column 1 (char '/') path $"); + } + } + + @Test + public void testDocumentWithCommentEndingInSlash() throws IOException { + JsonReader reader = new JsonReader(reader("/* foo *//")); + reader.setLenient(true); + try { + reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Expected value at line 1 column 10 (char '/') path $"); + } + } + + @Test + public void testStringWithLeadingSlash() throws IOException { + JsonReader reader = new JsonReader(reader("/x")); + reader.setLenient(true); + try { + reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Expected value at line 1 column 1 (char '/') path $"); + } + } + + @Test + public void testUnterminatedObject() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":\"android\"x")); + reader.setLenient(true); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextString()).isEqualTo("android"); + try { + reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Unterminated object at x at line 1 column 16 (char '\\0') path $.a"); + } + } + + @Test + public void testVeryLongQuotedString() throws IOException { + char[] stringChars = new char[1024 * 16]; + Arrays.fill(stringChars, 'x'); + String string = new String(stringChars); + String json = "[\"" + string + "\"]"; + JsonReader reader = new JsonReader(reader(json)); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo(string); + reader.endArray(); + } + + @Test + public void testVeryLongUnquotedString() throws IOException { + char[] stringChars = new char[1024 * 16]; + Arrays.fill(stringChars, 'x'); + String string = new String(stringChars); + String json = "[" + string + "]"; + JsonReader reader = new JsonReader(reader(json)); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo(string); + reader.endArray(); + } + + @Test + public void testVeryLongUnterminatedString() throws IOException { + char[] stringChars = new char[1024 * 16]; + Arrays.fill(stringChars, 'x'); + String string = new String(stringChars); + String json = "[" + string; + JsonReader reader = new JsonReader(reader(json)); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.nextString()).isEqualTo(string); + try { + reader.peek(); + fail(); + } catch (EOFException expected) { + } + } + + @Test + public void testSkipVeryLongUnquotedString() throws IOException { + JsonReader reader = new JsonReader(reader("[" + repeat('x', 8192) + "]")); + reader.setLenient(true); + reader.beginArray(); + reader.skipValue(); + reader.endArray(); + } + + @Test + public void testSkipTopLevelUnquotedString() throws IOException { + JsonReader reader = new JsonReader(reader(repeat('x', 8192))); + reader.setLenient(true); + reader.skipValue(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testSkipVeryLongQuotedString() throws IOException { + JsonReader reader = new JsonReader(reader("[\"" + repeat('x', 8192) + "\"]")); + reader.beginArray(); + reader.skipValue(); + reader.endArray(); + } + + @Test + public void testSkipTopLevelQuotedString() throws IOException { + JsonReader reader = new JsonReader(reader("\"" + repeat('x', 8192) + "\"")); + reader.setLenient(true); + reader.skipValue(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testStringAsNumberWithTruncatedExponent() throws IOException { + JsonReader reader = new JsonReader(reader("[123e]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(STRING); + } + + @Test + public void testStringAsNumberWithDigitAndNonDigitExponent() throws IOException { + JsonReader reader = new JsonReader(reader("[123e4b]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(STRING); + } + + @Test + public void testStringAsNumberWithNonDigitExponent() throws IOException { + JsonReader reader = new JsonReader(reader("[123eb]")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(STRING); + } + + @Test + public void testEmptyStringName() throws IOException { + JsonReader reader = new JsonReader(reader("{\"\":true}")); + reader.setLenient(true); + assertThat(reader.peek()).isEqualTo(BEGIN_OBJECT); + reader.beginObject(); + assertThat(reader.peek()).isEqualTo(NAME); + assertThat(reader.nextName()).isEqualTo(""); + assertThat(reader.peek()).isEqualTo(Token.BOOLEAN); + assertThat(reader.nextBoolean()).isTrue(); + assertThat(reader.peek()).isEqualTo(Token.END_OBJECT); + reader.endObject(); + assertThat(reader.peek()).isEqualTo(Token.END_DOCUMENT); + } + + @Test + public void testStrictExtraCommasInMaps() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":\"b\",}")); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextString()).isEqualTo("b"); + try { + reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Expected name at line 1 column 11 (char '\\0') path $.a"); + } + } + + @Test + public void testLenientExtraCommasInMaps() throws IOException { + JsonReader reader = new JsonReader(reader("{\"a\":\"b\",}")); + reader.setLenient(true); + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("a"); + assertThat(reader.nextString()).isEqualTo("b"); + try { + reader.peek(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Expected name at line 1 column 11 (char '\\0') path $.a"); + } + } + + private static String repeat(char c, int count) { + char[] array = new char[count]; + Arrays.fill(array, c); + return new String(array); + } + + @Test + public void testMalformedDocuments() throws IOException { + assertDocument("{]", BEGIN_OBJECT, MalformedDataException.class); + assertDocument("{,", BEGIN_OBJECT, MalformedDataException.class); + assertDocument("{{", BEGIN_OBJECT, MalformedDataException.class); + assertDocument("{[", BEGIN_OBJECT, MalformedDataException.class); + assertDocument("{:", BEGIN_OBJECT, MalformedDataException.class); + assertDocument("{\"name\",", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument("{\"name\",", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument("{\"name\":}", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument("{\"name\"::", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument("{\"name\":,", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument("{\"name\"=}", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument("{\"name\"=>}", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument( + "{\"name\"=>\"string\":", BEGIN_OBJECT, NAME, STRING, MalformedDataException.class); + assertDocument( + "{\"name\"=>\"string\"=", BEGIN_OBJECT, NAME, STRING, MalformedDataException.class); + assertDocument( + "{\"name\"=>\"string\"=>", BEGIN_OBJECT, NAME, STRING, MalformedDataException.class); + assertDocument("{\"name\"=>\"string\",", BEGIN_OBJECT, NAME, STRING, EOFException.class); + assertDocument("{\"name\"=>\"string\",\"name\"", BEGIN_OBJECT, NAME, STRING, NAME); + assertDocument("[}", BEGIN_ARRAY, MalformedDataException.class); + assertDocument("[,]", BEGIN_ARRAY, END_ARRAY); + assertDocument("{", BEGIN_OBJECT, EOFException.class); + assertDocument("{\"name\"", BEGIN_OBJECT, NAME, EOFException.class); + assertDocument("{\"name\",", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument("{'name'", BEGIN_OBJECT, NAME, EOFException.class); + assertDocument("{'name',", BEGIN_OBJECT, NAME, MalformedDataException.class); + assertDocument("{name", BEGIN_OBJECT, NAME, EOFException.class); + assertDocument("[", BEGIN_ARRAY, EOFException.class); + assertDocument("[string", BEGIN_ARRAY, STRING, EOFException.class); + assertDocument("[\"string\"", BEGIN_ARRAY, STRING, EOFException.class); + assertDocument("['string'", BEGIN_ARRAY, STRING, EOFException.class); + assertDocument("[123", BEGIN_ARRAY, NUMBER, EOFException.class); + assertDocument("[123,", BEGIN_ARRAY, NUMBER, EOFException.class); + assertDocument("{\"name\":123", BEGIN_OBJECT, NAME, NUMBER, EOFException.class); + assertDocument("{\"name\":123,", BEGIN_OBJECT, NAME, NUMBER, EOFException.class); + assertDocument("{\"name\":\"string\"", BEGIN_OBJECT, NAME, STRING, EOFException.class); + assertDocument("{\"name\":\"string\",", BEGIN_OBJECT, NAME, STRING, EOFException.class); + assertDocument("{\"name\":'string'", BEGIN_OBJECT, NAME, STRING, EOFException.class); + assertDocument("{\"name\":'string',", BEGIN_OBJECT, NAME, STRING, EOFException.class); + assertDocument("{\"name\":false", BEGIN_OBJECT, NAME, BOOLEAN, EOFException.class); + assertDocument("{\"name\":false,,", BEGIN_OBJECT, NAME, BOOLEAN, MalformedDataException.class); + } + + /** + * This test behaves slightly differently in Gson 2.2 and earlier. It fails during peek rather + * than during nextString(). + */ + @Test + public void testUnterminatedStringFailure() throws IOException { + JsonReader reader = new JsonReader(reader("[\"string")); + reader.setLenient(true); + reader.beginArray(); + assertThat(reader.peek()).isEqualTo(Token.STRING); + try { + reader.nextString(); + fail(); + } catch (MalformedDataException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo( + "Unterminated string at line 1 column 9 (char '[') path $[0]"); + } + } + + /** Regression test for an issue with buffer filling and consumeNonExecutePrefix. */ + @Test + public void testReadAcrossBuffers() throws IOException { + StringBuilder sb = new StringBuilder("#"); + // 1024 is the buffer size + for (int i = 0; i < 1024 - 3; i++) { + sb.append(' '); + } + sb.append("\n)]}'\n3"); + JsonReader reader = new JsonReader(reader(sb.toString())); + reader.setLenient(true); + Token token = reader.peek(); + assertThat(token).isEqualTo(Token.NUMBER); + } + + private static void assertStrictError(MalformedDataException exception, String expectedLocation) { + assertThat(exception) + .hasMessageThat() + .isEqualTo( + "Use JsonReader.setLenient(true) to accept malformed JSON at " + + expectedLocation); + } + + private static void assertUnexpectedStructureError( + IllegalStateException exception, + String expectedToken, + String actualToken, + String expectedLocation) { + assertThat(exception) + .hasMessageThat() + .isEqualTo( + "Expected " + + expectedToken + + " but was " + + actualToken + + " at " + + expectedLocation); + } + + private static void assertDocument(String document, Object... expectations) throws IOException { + JsonReader reader = new JsonReader(reader(document)); + reader.setLenient(true); + for (Object expectation : expectations) { + if (expectation == BEGIN_OBJECT) { + reader.beginObject(); + } else if (expectation == BEGIN_ARRAY) { + reader.beginArray(); + } else if (expectation == END_OBJECT) { + reader.endObject(); + } else if (expectation == END_ARRAY) { + reader.endArray(); + } else if (expectation == NAME) { + assertThat(reader.nextName()).isEqualTo("name"); + } else if (expectation == BOOLEAN) { + assertThat(reader.nextBoolean()).isFalse(); + } else if (expectation == STRING) { + assertThat(reader.nextString()).isEqualTo("string"); + } else if (expectation == NUMBER) { + assertThat(reader.nextInt()).isEqualTo(123); + } else if (expectation == NULL) { + reader.nextNull(); + } else if (expectation instanceof Class + && Exception.class.isAssignableFrom((Class) expectation)) { + try { + reader.peek(); + fail(); + } catch (Exception expected) { + assertThat(expected.getClass()).isEqualTo((Class) expectation); + } + } else { + throw new AssertionError("Unsupported expectation value: " + expectation); + } + } + } + + /** Returns a reader that returns one character at a time. */ + private static Reader reader(final String s) { + /* if (true) */ return new StringReader(s); + /* return new Reader() { + int position = 0; + @Override public int read(char[] buffer, int offset, int count) throws IOException { + if (position == s.length()) { + return -1; + } else if (count > 0) { + buffer[offset] = s.charAt(position++); + return 1; + } else { + throw new IllegalArgumentException(); + } + } + @Override public void close() throws IOException { + } + }; */ + } +} diff --git a/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonWriterTest.java b/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonWriterTest.java new file mode 100644 index 0000000..7b4b51e --- /dev/null +++ b/commons-serialize-json/src/test/java/io/gitlab/jfronny/commons/serialize/json/test/JsonWriterTest.java @@ -0,0 +1,1054 @@ +/* + * Copyright (C) 2010 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.gitlab.jfronny.commons.serialize.json.test; + +import io.gitlab.jfronny.commons.data.LazilyParsedNumber; +import io.gitlab.jfronny.commons.serialize.json.JsonWriter; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringWriter; +import java.math.BigDecimal; +import java.math.BigInteger; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; + +@SuppressWarnings("resource") +public final class JsonWriterTest { + + @Test + public void testWriteComments() throws IOException { + String expectedJson = "// comment at file head\n" + + "[\n" + + " // comment directly after context\n" + + " \"a\",\n" + + " // comment before context\n" + + " {\n" + + " // comment directly after object context\n" + + " \"b\": \"c\",\n" + + " // comment before name, after value\n" + + " \"d\": \"e\"\n" + + " }\n" + + " // comment before context end\n" + + "]\n" + + "// comment behind the object"; + + StringWriter sw = new StringWriter(); + JsonWriter jw = new JsonWriter(sw); + jw.setLenient(true); + jw.setIndent(" "); + + jw.comment("comment at file head") + .beginArray() + .comment("comment directly after context") + .value("a") + .comment("comment before context") + .beginObject() + .comment("comment directly after object context") + .name("b").value("c") + .comment("comment before name, after value") + .name("d").value("e") + .endObject() + .comment("comment before context end") + .endArray() + .comment("comment behind the object"); + + jw.close(); + assertThat(sw.toString()).isEqualTo(expectedJson); + sw.close(); + } + + @Test + public void testDefaultStrictness() throws IOException { + JsonWriter jsonWriter = new JsonWriter(new StringWriter()); + assertThat(jsonWriter.isLenient()).isEqualTo(false); + jsonWriter.value(false); + jsonWriter.close(); + } + + @SuppressWarnings("deprecation") // for JsonWriter.setLenient + @Test + public void testSetLenientTrue() throws IOException { + JsonWriter jsonWriter = new JsonWriter(new StringWriter()); + jsonWriter.setLenient(true); + assertThat(jsonWriter.isLenient()).isEqualTo(true); + jsonWriter.value(false); + jsonWriter.close(); + } + + @SuppressWarnings("deprecation") // for JsonWriter.setLenient + @Test + public void testSetLenientFalse() throws IOException { + JsonWriter jsonWriter = new JsonWriter(new StringWriter()); + jsonWriter.setLenient(false); + assertThat(jsonWriter.isLenient()).isEqualTo(false); + jsonWriter.value(false); + jsonWriter.close(); + } + + @Test + public void testTopLevelValueTypes() throws IOException { + StringWriter string1 = new StringWriter(); + JsonWriter writer1 = new JsonWriter(string1); + writer1.value(true); + writer1.close(); + assertThat(string1.toString()).isEqualTo("true"); + + StringWriter string2 = new StringWriter(); + JsonWriter writer2 = new JsonWriter(string2); + writer2.nullValue(); + writer2.close(); + assertThat(string2.toString()).isEqualTo("null"); + + StringWriter string3 = new StringWriter(); + JsonWriter writer3 = new JsonWriter(string3); + writer3.value(123); + writer3.close(); + assertThat(string3.toString()).isEqualTo("123"); + + StringWriter string4 = new StringWriter(); + JsonWriter writer4 = new JsonWriter(string4); + writer4.value(123.4); + writer4.close(); + assertThat(string4.toString()).isEqualTo("123.4"); + + StringWriter string5 = new StringWriter(); + JsonWriter writert = new JsonWriter(string5); + writert.value("a"); + writert.close(); + assertThat(string5.toString()).isEqualTo("\"a\""); + } + + @Test + public void testNameAsTopLevelValue() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> jsonWriter.name("hello")); + assertThat(e).hasMessageThat().isEqualTo("Please begin an object before writing a name."); + + jsonWriter.value(12); + jsonWriter.close(); + + e = assertThrows(IllegalStateException.class, () -> jsonWriter.name("hello")); + assertThat(e).hasMessageThat().isEqualTo("JsonWriter is closed."); + } + + @Test + public void testNameInArray() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + + jsonWriter.beginArray(); + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> jsonWriter.name("hello")); + assertThat(e).hasMessageThat().isEqualTo("Please begin an object before writing a name."); + + jsonWriter.value(12); + e = assertThrows(IllegalStateException.class, () -> jsonWriter.name("hello")); + assertThat(e).hasMessageThat().isEqualTo("Please begin an object before writing a name."); + + jsonWriter.endArray(); + jsonWriter.close(); + + assertThat(stringWriter.toString()).isEqualTo("[12]"); + } + + @Test + public void testTwoNames() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + jsonWriter.name("a"); + try { + jsonWriter.name("a"); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Already wrote a name, expecting a value."); + } + } + + @Test + public void testNameWithoutValue() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + jsonWriter.name("a"); + try { + jsonWriter.endObject(); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Dangling name: a"); + } + } + + @Test + public void testValueWithoutName() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + try { + jsonWriter.value(true); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Nesting problem."); + } + } + + @Test + public void testMultipleTopLevelValues() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setLenient(false); + jsonWriter.beginArray().endArray(); + + IllegalStateException expected = + assertThrows(IllegalStateException.class, jsonWriter::beginArray); + assertThat(expected).hasMessageThat().isEqualTo("JSON must have only one top-level value."); + } + + @Test + public void testMultipleTopLevelValuesStrict() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setLenient(false); + jsonWriter.beginArray().endArray(); + + IllegalStateException expected = + assertThrows(IllegalStateException.class, jsonWriter::beginArray); + assertThat(expected).hasMessageThat().isEqualTo("JSON must have only one top-level value."); + } + + @Test + public void testMultipleTopLevelValuesLenient() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter writer = new JsonWriter(stringWriter); + writer.setLenient(true); + writer.beginArray(); + writer.endArray(); + writer.beginArray(); + writer.endArray(); + writer.close(); + assertThat(stringWriter.toString()).isEqualTo("[][]"); + } + + @Test + public void testBadNestingObject() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.beginObject(); + try { + jsonWriter.endArray(); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Nesting problem."); + } + } + + @Test + public void testBadNestingArray() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.beginArray(); + try { + jsonWriter.endObject(); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Nesting problem."); + } + } + + @Test + public void testNullName() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + try { + jsonWriter.name(null); + fail(); + } catch (NullPointerException expected) { + } + } + + @Test + public void testNullStringValue() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + jsonWriter.name("a"); + jsonWriter.value((String) null); + jsonWriter.endObject(); + assertThat(stringWriter.toString()).isEqualTo("{\"a\":null}"); + } + + @Test + public void testJsonValue() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + jsonWriter.name("a"); + jsonWriter.literalValue("{\"b\":true}"); + jsonWriter.name("c"); + jsonWriter.value(1); + jsonWriter.endObject(); + assertThat(stringWriter.toString()).isEqualTo("{\"a\":{\"b\":true},\"c\":1}"); + } + + private static void assertNonFiniteFloatsExceptions(JsonWriter jsonWriter) throws IOException { + jsonWriter.beginArray(); + + IllegalArgumentException expected = + assertThrows(IllegalArgumentException.class, () -> jsonWriter.value(Float.NaN)); + assertThat(expected).hasMessageThat().isEqualTo("Numeric values must be finite, but was NaN"); + + expected = + assertThrows( + IllegalArgumentException.class, () -> jsonWriter.value(Float.NEGATIVE_INFINITY)); + assertThat(expected) + .hasMessageThat() + .isEqualTo("Numeric values must be finite, but was -Infinity"); + + expected = + assertThrows( + IllegalArgumentException.class, () -> jsonWriter.value(Float.POSITIVE_INFINITY)); + assertThat(expected) + .hasMessageThat() + .isEqualTo("Numeric values must be finite, but was Infinity"); + } + + @Test + public void testNonFiniteFloats() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + assertNonFiniteFloatsExceptions(jsonWriter); + } + + @Test + public void testNonFiniteFloatsWhenStrict() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setLenient(false); + assertNonFiniteFloatsExceptions(jsonWriter); + } + + private static void assertNonFiniteDoublesExceptions(JsonWriter jsonWriter) throws IOException { + jsonWriter.beginArray(); + + IllegalArgumentException expected = + assertThrows(IllegalArgumentException.class, () -> jsonWriter.value(Double.NaN)); + assertThat(expected).hasMessageThat().isEqualTo("Numeric values must be finite, but was NaN"); + + expected = + assertThrows( + IllegalArgumentException.class, () -> jsonWriter.value(Double.NEGATIVE_INFINITY)); + assertThat(expected) + .hasMessageThat() + .isEqualTo("Numeric values must be finite, but was -Infinity"); + + expected = + assertThrows( + IllegalArgumentException.class, () -> jsonWriter.value(Double.POSITIVE_INFINITY)); + assertThat(expected) + .hasMessageThat() + .isEqualTo("Numeric values must be finite, but was Infinity"); + } + + @Test + public void testNonFiniteDoubles() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + assertNonFiniteDoublesExceptions(jsonWriter); + } + + @Test + public void testNonFiniteDoublesWhenStrict() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setLenient(false); + assertNonFiniteDoublesExceptions(jsonWriter); + } + + private static void assertNonFiniteNumbersExceptions(JsonWriter jsonWriter) throws IOException { + jsonWriter.beginArray(); + + IllegalArgumentException expected = + assertThrows( + IllegalArgumentException.class, () -> jsonWriter.value(Double.valueOf(Double.NaN))); + assertThat(expected).hasMessageThat().isEqualTo("Numeric values must be finite, but was NaN"); + + expected = + assertThrows( + IllegalArgumentException.class, + () -> jsonWriter.value(Double.valueOf(Double.NEGATIVE_INFINITY))); + assertThat(expected) + .hasMessageThat() + .isEqualTo("Numeric values must be finite, but was -Infinity"); + + expected = + assertThrows( + IllegalArgumentException.class, + () -> jsonWriter.value(Double.valueOf(Double.POSITIVE_INFINITY))); + assertThat(expected) + .hasMessageThat() + .isEqualTo("Numeric values must be finite, but was Infinity"); + + expected = + assertThrows( + IllegalArgumentException.class, + () -> jsonWriter.value(new LazilyParsedNumber("Infinity"))); + assertThat(expected) + .hasMessageThat() + .isEqualTo("Numeric values must be finite, but was Infinity"); + } + + @Test + public void testNonFiniteNumbers() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + assertNonFiniteNumbersExceptions(jsonWriter); + } + + @Test + public void testNonFiniteNumbersWhenStrict() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setLenient(false); + assertNonFiniteNumbersExceptions(jsonWriter); + } + + @Test + public void testNonFiniteFloatsWhenLenient() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setLenient(true); + jsonWriter.beginArray(); + jsonWriter.value(Float.NaN); + jsonWriter.value(Float.NEGATIVE_INFINITY); + jsonWriter.value(Float.POSITIVE_INFINITY); + jsonWriter.endArray(); + assertThat(stringWriter.toString()).isEqualTo("[NaN,-Infinity,Infinity]"); + } + + @Test + public void testNonFiniteDoublesWhenLenient() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setLenient(true); + jsonWriter.beginArray(); + jsonWriter.value(Double.NaN); + jsonWriter.value(Double.NEGATIVE_INFINITY); + jsonWriter.value(Double.POSITIVE_INFINITY); + jsonWriter.endArray(); + assertThat(stringWriter.toString()).isEqualTo("[NaN,-Infinity,Infinity]"); + } + + @Test + public void testNonFiniteNumbersWhenLenient() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setLenient(true); + jsonWriter.beginArray(); + jsonWriter.value(Double.valueOf(Double.NaN)); + jsonWriter.value(Double.valueOf(Double.NEGATIVE_INFINITY)); + jsonWriter.value(Double.valueOf(Double.POSITIVE_INFINITY)); + jsonWriter.value(new LazilyParsedNumber("Infinity")); + jsonWriter.endArray(); + assertThat(stringWriter.toString()).isEqualTo("[NaN,-Infinity,Infinity,Infinity]"); + } + + @Test + public void testFloats() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.value(-0.0f); + jsonWriter.value(1.0f); + jsonWriter.value(Float.MAX_VALUE); + jsonWriter.value(Float.MIN_VALUE); + jsonWriter.value(0.0f); + jsonWriter.value(-0.5f); + jsonWriter.value(2.2250739E-38f); + jsonWriter.value(3.723379f); + jsonWriter.value((float) Math.PI); + jsonWriter.value((float) Math.E); + jsonWriter.endArray(); + jsonWriter.close(); + assertThat(stringWriter.toString()) + .isEqualTo( + "[-0.0," + + "1.0," + + "3.4028235E38," + + "1.4E-45," + + "0.0," + + "-0.5," + + "2.2250739E-38," + + "3.723379," + + "3.1415927," + + "2.7182817]"); + } + + @Test + public void testDoubles() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.value(-0.0); + jsonWriter.value(1.0); + jsonWriter.value(Double.MAX_VALUE); + jsonWriter.value(Double.MIN_VALUE); + jsonWriter.value(0.0); + jsonWriter.value(-0.5); + jsonWriter.value(2.2250738585072014E-308); + jsonWriter.value(Math.PI); + jsonWriter.value(Math.E); + jsonWriter.endArray(); + jsonWriter.close(); + assertThat(stringWriter.toString()) + .isEqualTo( + "[-0.0," + + "1.0," + + "1.7976931348623157E308," + + "4.9E-324," + + "0.0," + + "-0.5," + + "2.2250738585072014E-308," + + "3.141592653589793," + + "2.718281828459045]"); + } + + @Test + public void testLongs() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.value(0); + jsonWriter.value(1); + jsonWriter.value(-1); + jsonWriter.value(Long.MIN_VALUE); + jsonWriter.value(Long.MAX_VALUE); + jsonWriter.endArray(); + jsonWriter.close(); + assertThat(stringWriter.toString()) + .isEqualTo("[0," + "1," + "-1," + "-9223372036854775808," + "9223372036854775807]"); + } + + @Test + public void testNumbers() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.value(new BigInteger("0")); + jsonWriter.value(new BigInteger("9223372036854775808")); + jsonWriter.value(new BigInteger("-9223372036854775809")); + jsonWriter.value(new BigDecimal("3.141592653589793238462643383")); + jsonWriter.endArray(); + jsonWriter.close(); + assertThat(stringWriter.toString()) + .isEqualTo( + "[0," + + "9223372036854775808," + + "-9223372036854775809," + + "3.141592653589793238462643383]"); + } + + /** Tests writing {@code Number} instances which are not one of the standard JDK ones. */ + @Test + public void testNumbersCustomClass() throws IOException { + String[] validNumbers = { + "-0.0", + "1.0", + "1.7976931348623157E308", + "4.9E-324", + "0.0", + "0.00", + "-0.5", + "2.2250738585072014E-308", + "3.141592653589793", + "2.718281828459045", + "0", + "0.01", + "0e0", + "1e+0", + "1e-0", + "1e0000", // leading 0 is allowed for exponent + "1e00001", + "1e+1", + }; + + for (String validNumber : validNumbers) { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + + jsonWriter.value(new LazilyParsedNumber(validNumber)); + jsonWriter.close(); + + assertThat(stringWriter.toString()).isEqualTo(validNumber); + } + } + + @Test + public void testMalformedNumbers() throws IOException { + String[] malformedNumbers = { + "some text", + "", + ".", + "00", + "01", + "-00", + "-", + "--1", + "+1", // plus sign is not allowed for integer part + "+", + "1,0", + "1,000", + "0.", // decimal digit is required + ".1", // integer part is required + "e1", + ".e1", + ".1e1", + "1e-", + "1e+", + "1e--1", + "1e+-1", + "1e1e1", + "1+e1", + "1e1.0", + }; + + for (String malformedNumber : malformedNumbers) { + JsonWriter jsonWriter = new JsonWriter(new StringWriter()); + try { + jsonWriter.value(new LazilyParsedNumber(malformedNumber)); + fail("Should have failed writing malformed number: " + malformedNumber); + } catch (IllegalArgumentException e) { + assertThat(e) + .hasMessageThat() + .isEqualTo( + "String created by class io.gitlab.jfronny.commons.data.LazilyParsedNumber is not a valid" + + " number: " + + malformedNumber); + } + } + } + + @Test + public void testBooleans() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.value(true); + jsonWriter.value(false); + jsonWriter.endArray(); + assertThat(stringWriter.toString()).isEqualTo("[true,false]"); + } + + @Test + public void testBoxedBooleans() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.value((Boolean) true); + jsonWriter.value((Boolean) false); + jsonWriter.value((Boolean) null); + jsonWriter.endArray(); + assertThat(stringWriter.toString()).isEqualTo("[true,false,null]"); + } + + @Test + public void testNulls() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.nullValue(); + jsonWriter.endArray(); + assertThat(stringWriter.toString()).isEqualTo("[null]"); + } + + @Test + public void testStrings() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.value("a"); + jsonWriter.value("a\""); + jsonWriter.value("\""); + jsonWriter.value(":"); + jsonWriter.value(","); + jsonWriter.value("\b"); + jsonWriter.value("\f"); + jsonWriter.value("\n"); + jsonWriter.value("\r"); + jsonWriter.value("\t"); + jsonWriter.value(" "); + jsonWriter.value("\\"); + jsonWriter.value("{"); + jsonWriter.value("}"); + jsonWriter.value("["); + jsonWriter.value("]"); + jsonWriter.value("\0"); + jsonWriter.value("\u0019"); + jsonWriter.endArray(); + assertThat(stringWriter.toString()) + .isEqualTo( + "[\"a\"," + + "\"a\\\"\"," + + "\"\\\"\"," + + "\":\"," + + "\",\"," + + "\"\\b\"," + + "\"\\f\"," + + "\"\\n\"," + + "\"\\r\"," + + "\"\\t\"," + + "\" \"," + + "\"\\\\\"," + + "\"{\"," + + "\"}\"," + + "\"[\"," + + "\"]\"," + + "\"\\0\"," + + "\"\\u0019\"]"); + } + + @Test + public void testUnicodeLineBreaksEscaped() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.value("\u2028 \u2029"); + jsonWriter.endArray(); + // JSON specification does not require that they are escaped, but Gson escapes them for + // compatibility with JavaScript where they are considered line breaks + assertThat(stringWriter.toString()).isEqualTo("[\"\\u2028 \\u2029\"]"); + } + + @Test + public void testEmptyArray() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.endArray(); + assertThat(stringWriter.toString()).isEqualTo("[]"); + } + + @Test + public void testEmptyObject() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + jsonWriter.endObject(); + assertThat(stringWriter.toString()).isEqualTo("{}"); + } + + @Test + public void testObjectsInArrays() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginArray(); + jsonWriter.beginObject(); + jsonWriter.name("a").value(5); + jsonWriter.name("b").value(false); + jsonWriter.endObject(); + jsonWriter.beginObject(); + jsonWriter.name("c").value(6); + jsonWriter.name("d").value(true); + jsonWriter.endObject(); + jsonWriter.endArray(); + assertThat(stringWriter.toString()) + .isEqualTo("[{\"a\":5,\"b\":false}," + "{\"c\":6,\"d\":true}]"); + } + + @Test + public void testArraysInObjects() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + jsonWriter.name("a"); + jsonWriter.beginArray(); + jsonWriter.value(5); + jsonWriter.value(false); + jsonWriter.endArray(); + jsonWriter.name("b"); + jsonWriter.beginArray(); + jsonWriter.value(6); + jsonWriter.value(true); + jsonWriter.endArray(); + jsonWriter.endObject(); + assertThat(stringWriter.toString()).isEqualTo("{\"a\":[5,false]," + "\"b\":[6,true]}"); + } + + @Test + public void testDeepNestingArrays() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + for (int i = 0; i < 20; i++) { + jsonWriter.beginArray(); + } + for (int i = 0; i < 20; i++) { + jsonWriter.endArray(); + } + assertThat(stringWriter.toString()).isEqualTo("[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]"); + } + + @Test + public void testDeepNestingObjects() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + for (int i = 0; i < 20; i++) { + jsonWriter.name("a"); + jsonWriter.beginObject(); + } + for (int i = 0; i < 20; i++) { + jsonWriter.endObject(); + } + jsonWriter.endObject(); + assertThat(stringWriter.toString()) + .isEqualTo( + "{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":" + + "{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{" + + "}}}}}}}}}}}}}}}}}}}}}"); + } + + @Test + public void testRepeatedName() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.beginObject(); + jsonWriter.name("a").value(true); + jsonWriter.name("a").value(false); + jsonWriter.endObject(); + // JsonWriter doesn't attempt to detect duplicate names + assertThat(stringWriter.toString()).isEqualTo("{\"a\":true,\"a\":false}"); + } + + @Test + public void testPrettyPrintObject() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setIndent(" "); + + jsonWriter.beginObject(); + jsonWriter.name("a").value(true); + jsonWriter.name("b").value(false); + jsonWriter.name("c").value(5.0); + jsonWriter.name("e").nullValue(); + jsonWriter.name("f").beginArray(); + jsonWriter.value(6.0); + jsonWriter.value(7.0); + jsonWriter.endArray(); + jsonWriter.name("g").beginObject(); + jsonWriter.name("h").value(8.0); + jsonWriter.name("i").value(9.0); + jsonWriter.endObject(); + jsonWriter.endObject(); + + String expected = + "{\n" + + " \"a\": true,\n" + + " \"b\": false,\n" + + " \"c\": 5.0,\n" + + " \"e\": null,\n" + + " \"f\": [\n" + + " 6.0,\n" + + " 7.0\n" + + " ],\n" + + " \"g\": {\n" + + " \"h\": 8.0,\n" + + " \"i\": 9.0\n" + + " }\n" + + "}"; + assertThat(stringWriter.toString()).isEqualTo(expected); + } + + @Test + public void testPrettyPrintArray() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setIndent(" "); + + jsonWriter.beginArray(); + jsonWriter.value(true); + jsonWriter.value(false); + jsonWriter.value(5.0); + jsonWriter.nullValue(); + jsonWriter.beginObject(); + jsonWriter.name("a").value(6.0); + jsonWriter.name("b").value(7.0); + jsonWriter.endObject(); + jsonWriter.beginArray(); + jsonWriter.value(8.0); + jsonWriter.value(9.0); + jsonWriter.endArray(); + jsonWriter.endArray(); + + String expected = + "[\n" + + " true,\n" + + " false,\n" + + " 5.0,\n" + + " null,\n" + + " {\n" + + " \"a\": 6.0,\n" + + " \"b\": 7.0\n" + + " },\n" + + " [\n" + + " 8.0,\n" + + " 9.0\n" + + " ]\n" + + "]"; + assertThat(stringWriter.toString()).isEqualTo(expected); + } + + @Test + public void testClosedWriterThrowsOnStructure() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter writer = new JsonWriter(stringWriter); + writer.beginArray(); + writer.endArray(); + writer.close(); + try { + writer.beginArray(); + fail(); + } catch (IllegalStateException expected) { + } + try { + writer.endArray(); + fail(); + } catch (IllegalStateException expected) { + } + try { + writer.beginObject(); + fail(); + } catch (IllegalStateException expected) { + } + try { + writer.endObject(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void testClosedWriterThrowsOnName() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter writer = new JsonWriter(stringWriter); + writer.beginArray(); + writer.endArray(); + writer.close(); + try { + writer.name("a"); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void testClosedWriterThrowsOnValue() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter writer = new JsonWriter(stringWriter); + writer.beginArray(); + writer.endArray(); + writer.close(); + try { + writer.value("a"); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void testClosedWriterThrowsOnFlush() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter writer = new JsonWriter(stringWriter); + writer.beginArray(); + writer.endArray(); + writer.close(); + try { + writer.flush(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void testWriterCloseIsIdempotent() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter writer = new JsonWriter(stringWriter); + writer.beginArray(); + writer.endArray(); + writer.close(); + writer.close(); + } + + @Test + public void testSetGetFormattingStyle() throws IOException { + String lineSeparator = "\r\n"; + + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + // Default should be FormattingStyle.COMPACT + jsonWriter.setIndent(" \t ").setNewline(lineSeparator); + + jsonWriter.beginArray(); + jsonWriter.value(true); + jsonWriter.value("text"); + jsonWriter.value(5.0); + jsonWriter.nullValue(); + jsonWriter.endArray(); + + String expected = + "[\r\n" // + + " \t true,\r\n" // + + " \t \"text\",\r\n" // + + " \t 5.0,\r\n" // + + " \t null\r\n" // + + "]"; + assertThat(stringWriter.toString()).isEqualTo(expected); + + assertThat(jsonWriter.getNewline()).isEqualTo(lineSeparator); + } + + @Test + public void testIndentOverwritesFormattingStyle() throws IOException { + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + // Should overwrite formatting style + jsonWriter.setIndent(" "); + + jsonWriter.beginObject(); + jsonWriter.name("a"); + jsonWriter.beginArray(); + jsonWriter.value(1); + jsonWriter.value(2); + jsonWriter.endArray(); + jsonWriter.endObject(); + + String expected = + "{\n" // + + " \"a\": [\n" // + + " 1,\n" // + + " 2\n" // + + " ]\n" // + + "}"; + assertThat(stringWriter.toString()).isEqualTo(expected); + } +} diff --git a/commons-serialize/build.gradle.kts b/commons-serialize/build.gradle.kts index 6852703..2f34502 100644 --- a/commons-serialize/build.gradle.kts +++ b/commons-serialize/build.gradle.kts @@ -1,9 +1,11 @@ -import io.gitlab.jfronny.scripts.* - plugins { commons.library } +dependencies { + implementation(projects.commons) +} + publishing { publications { create("maven") { diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/StringEscapeUtil.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/StringEscapeUtil.java new file mode 100644 index 0000000..2d4da9f --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/StringEscapeUtil.java @@ -0,0 +1,56 @@ +package io.gitlab.jfronny.commons.serialize; + +/** + * Utilities methods for escaping strings, extracted from gsons JsonWriter. + * @author JFronny + */ +public class StringEscapeUtil { + /* + * From RFC 8259, "All Unicode characters may be placed within the + * quotation marks except for the characters that must be escaped: + * quotation mark, reverse solidus, and the control characters + * (U+0000 through U+001F)." + * + * We also escape '\u2028' and '\u2029', which JavaScript interprets as + * newline characters. This prevents eval() from failing with a syntax + * error. http://code.google.com/p/google-gson/issues/detail?id=341 + */ + private static final String[] REPLACEMENT_CHARS; + + static { + REPLACEMENT_CHARS = new String[128]; + for (int i = 0; i <= 0x1f; i++) { + REPLACEMENT_CHARS[i] = String.format("\\u%04x", i); + } + REPLACEMENT_CHARS['"'] = "\\\""; + REPLACEMENT_CHARS['\\'] = "\\\\"; + REPLACEMENT_CHARS['\t'] = "\\t"; + REPLACEMENT_CHARS['\b'] = "\\b"; + REPLACEMENT_CHARS['\n'] = "\\n"; + REPLACEMENT_CHARS['\r'] = "\\r"; + REPLACEMENT_CHARS['\f'] = "\\f"; + REPLACEMENT_CHARS['\0'] = "\\0"; + } + + /** + * Returns the replacement for the character, or null if the character does not need to be escaped. + * @param c the character to escape + * @return the replacement for the character, or null if the character does not need to be escaped + */ + public static String getReplacement(char c) { + String replacement; + if (c < 128) { + replacement = REPLACEMENT_CHARS[c]; + if (replacement == null) { + return null; + } + } else if (c == '\u2028') { + replacement = "\\u2028"; + } else if (c == '\u2029') { + replacement = "\\u2029"; + } else { + return null; + } + return replacement; + } +} diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/MalformedDataException.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/MalformedDataException.java new file mode 100644 index 0000000..d4e42a7 --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/MalformedDataException.java @@ -0,0 +1,17 @@ +package io.gitlab.jfronny.commons.serialize.stream; + +import java.io.IOException; + +public class MalformedDataException extends IOException { + public MalformedDataException(String msg) { + super(msg); + } + + public MalformedDataException(String msg, Throwable throwable) { + super(msg, throwable); + } + + public MalformedDataException(Throwable throwable) { + super(throwable); + } +} diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/SerializeReader.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/SerializeReader.java new file mode 100644 index 0000000..a2bbbc9 --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/SerializeReader.java @@ -0,0 +1,81 @@ +package io.gitlab.jfronny.commons.serialize.stream; + +import io.gitlab.jfronny.commons.SamWithReceiver; + +public abstract class SerializeReader> implements AutoCloseable { + protected boolean lenient = false; + protected boolean serializeSpecialFloatingPointValues = false; + + public boolean isLenient() { + return lenient; + } + + public T setLenient(boolean lenient) { + this.lenient = lenient; + if (lenient) return setSerializeSpecialFloatingPointValues(true); + return (T) this; + } + + public boolean isSerializeSpecialFloatingPointValues() { + return serializeSpecialFloatingPointValues; + } + + public T setSerializeSpecialFloatingPointValues(boolean serializeSpecialFloatingPointValues) { + this.serializeSpecialFloatingPointValues = serializeSpecialFloatingPointValues; + return (T) this; + } + + public abstract T beginArray() throws TEx; + public abstract T endArray() throws TEx; + public R array(SerializeReaderFunction consumer) throws TEx { + beginArray(); + var result = consumer.accept((T) this); + endArray(); + return result; + } + public abstract T beginObject() throws TEx; + public abstract T endObject() throws TEx; + public R object(SerializeReaderFunction consumer) throws TEx { + beginObject(); + var result = consumer.accept((T) this); + endObject(); + return result; + } + + public abstract boolean hasNext() throws TEx; + public abstract Token peek() throws TEx; + + public abstract String nextName() throws TEx; + public abstract String nextString() throws TEx; + public abstract boolean nextBoolean() throws TEx; + public abstract void nextNull() throws TEx; + public double nextDouble() throws TEx { + return nextNumber().doubleValue(); + } + public long nextLong() throws TEx { + return nextNumber().longValue(); + } + public int nextInt() throws TEx { + return nextNumber().intValue(); + } + public abstract Number nextNumber() throws TEx; + public abstract void skipValue() throws TEx; + + public abstract String getPath(); + public abstract String getPreviousPath(); + + @Override + public String toString() { + return getClass().getSimpleName() + locationString(); + } + + protected String locationString() { + return " at path " + getPath(); + } + + + @SamWithReceiver + public interface SerializeReaderFunction, R> { + R accept(T reader) throws TEx; + } +} diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/SerializeWriter.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/SerializeWriter.java new file mode 100644 index 0000000..a9086a0 --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/SerializeWriter.java @@ -0,0 +1,138 @@ +package io.gitlab.jfronny.commons.serialize.stream; + +import io.gitlab.jfronny.commons.SamWithReceiver; + +import java.io.Flushable; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +public abstract class SerializeWriter> implements AutoCloseable, Flushable { + private static final Pattern VALID_JSON_NUMBER_PATTERN = + Pattern.compile("-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][-+]?[0-9]+)?"); + + protected boolean lenient = false; + protected boolean serializeNulls = true; + protected boolean serializeSpecialFloatingPointValues = false; + + public boolean isLenient() { + return lenient; + } + + public T setLenient(boolean lenient) { + this.lenient = lenient; + if (lenient) return setSerializeSpecialFloatingPointValues(true).setSerializeNulls(true); + return (T) this; + } + + public boolean isSerializeNulls() { + return serializeNulls; + } + + public T setSerializeNulls(boolean serializeNulls) { + this.serializeNulls = serializeNulls; + return (T) this; + } + + public boolean isSerializeSpecialFloatingPointValues() { + return serializeSpecialFloatingPointValues; + } + + public T setSerializeSpecialFloatingPointValues(boolean serializeSpecialFloatingPointValues) { + this.serializeSpecialFloatingPointValues = serializeSpecialFloatingPointValues; + return (T) this; + } + + public abstract T beginArray() throws TEx; + public abstract T endArray() throws TEx; + public T array(SerializeWriterConsumer consumer) throws TEx { + return consumer.accept(this.beginArray()).endObject(); + } + public abstract T beginObject() throws TEx; + public abstract T endObject() throws TEx; + public T object(SerializeWriterConsumer consumer) throws TEx { + return consumer.accept(this.beginObject()).endObject(); + } + + public abstract T comment(String comment) throws TEx; + public abstract T name(String name) throws TEx; + public T nullValue() throws TEx { + if (serializeNulls) { + return literalValue("null"); + } else { + throw new IllegalArgumentException("Null values are not allowed"); + } + } + public abstract T value(String value) throws TEx; + public T value(boolean value) throws TEx { + return literalValue(value ? "true" : "false"); + } + public T value(Boolean value) throws TEx { + return value == null ? nullValue() : value(value.booleanValue()); + } + public T value(float value) throws TEx { + if (!serializeSpecialFloatingPointValues && (Float.isNaN(value) || Float.isInfinite(value))) + throw new IllegalArgumentException("Numeric values must be finite, but was " + value); + return literalValue(Float.toString(value)); + } + public T value(Float value) throws TEx { + return value == null ? nullValue() : value(value.floatValue()); + } + public T value(double value) throws TEx { + if (!serializeSpecialFloatingPointValues && (Double.isNaN(value) || Double.isInfinite(value))) + throw new IllegalArgumentException("Numeric values must be finite, but was " + value); + return literalValue(Double.toString(value)); + } + public T value(Double value) throws TEx { + return value == null ? nullValue() : value(value.doubleValue()); + } + public T value(long value) throws TEx { + return literalValue(Long.toString(value)); + } + public T value(Long value) throws TEx { + return value == null ? nullValue() : value(value.longValue()); + } + public T value(Number value) throws TEx { + if (value == null) return nullValue(); + String s = value.toString(); + if (s.equals("NaN") || s.equals("Infinity") || s.equals("-Infinity")) { + if (!serializeSpecialFloatingPointValues) { + throw new IllegalArgumentException("Numeric values must be finite, but was " + s); + } + } else { + Class numberClass = value.getClass(); + if (!isTrustedNumberClass(numberClass) && !VALID_JSON_NUMBER_PATTERN.matcher(s).matches()) { + throw new IllegalArgumentException("String created by " + numberClass + " is not a valid number: " + s); + } + } + return literalValue(s); + } + + private static boolean isTrustedNumberClass(Class c) { + return c == Integer.class + || c == Long.class + || c == Double.class + || c == Float.class + || c == Byte.class + || c == Short.class + || c == BigDecimal.class + || c == BigInteger.class + || c == AtomicInteger.class + || c == AtomicLong.class; + } + + /** + * Writes a literal value to the output without quoting or escaping. + * This may not be supported by all implementations, if not supported an {@link UnsupportedOperationException} will be thrown. + * @param value the literal value to write + * @return this writer + */ + public abstract T literalValue(String value) throws TEx; + + @SamWithReceiver + public interface SerializeWriterConsumer> { + T accept(T writer) throws TEx; + } +} diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/Token.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/Token.java new file mode 100644 index 0000000..09dbca5 --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/Token.java @@ -0,0 +1,5 @@ +package io.gitlab.jfronny.commons.serialize.stream; + +public enum Token { + BEGIN_ARRAY, END_ARRAY, BEGIN_OBJECT, END_OBJECT, NAME, STRING, NUMBER, BOOLEAN, NULL, END_DOCUMENT +} diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/DataElement.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/DataElement.java new file mode 100644 index 0000000..20b831c --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/DataElement.java @@ -0,0 +1,48 @@ +package io.gitlab.jfronny.commons.serialize.stream.emulated; + +import io.gitlab.jfronny.commons.data.LinkedTreeMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public sealed interface DataElement { + record Null() implements DataElement {} + sealed interface Primitive extends DataElement { + java.lang.String asString(); + + record Boolean(boolean value) implements Primitive { + @Override + public java.lang.String asString() { + return value ? "true" : "false"; + } + } + record Number(java.lang.Number value) implements Primitive { + public Number { + Objects.requireNonNull(value); + } + + @Override + public java.lang.String asString() { + return value.toString(); + } + } + record String(java.lang.String value) implements Primitive { + @Override + public java.lang.String asString() { + return value; + } + } + } + record Object(Map members) implements DataElement { + public Object() { + this(new LinkedTreeMap<>(false)); + } + } + record Array(List elements) implements DataElement { + public Array() { + this(new ArrayList<>()); + } + } +} diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/DataElementSerializer.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/DataElementSerializer.java new file mode 100644 index 0000000..cb69d1d --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/DataElementSerializer.java @@ -0,0 +1,61 @@ +package io.gitlab.jfronny.commons.serialize.stream.emulated; + +import io.gitlab.jfronny.commons.serialize.stream.SerializeReader; +import io.gitlab.jfronny.commons.serialize.stream.SerializeWriter; + +import java.util.Map; + +public class DataElementSerializer { + public static > void serialize(DataElement element, T out) throws TEx { + switch (element) { + case DataElement.Array(var elements) -> out.array(b -> { + for (DataElement e : elements) { + serialize(e, b); + } + return b; + }); + case DataElement.Null n -> out.nullValue(); + case DataElement.Object(var members) -> out.object(b -> { + for (Map.Entry e : members.entrySet()) { + b.name(e.getKey()); + serialize(e.getValue(), b); + } + return b; + }); + case DataElement.Primitive p -> { + switch (p) { + case DataElement.Primitive.Boolean(var value) -> out.value(value); + case DataElement.Primitive.Number(var value) -> out.value(value); + case DataElement.Primitive.String(var value) -> out.value(value); + } + } + } + } + + public static > DataElement deserialize(T in) throws TEx { + return switch (in.peek()) { + case STRING -> new DataElement.Primitive.String(in.nextString()); + case NUMBER -> new DataElement.Primitive.Number(in.nextNumber()); + case BOOLEAN -> new DataElement.Primitive.Boolean(in.nextBoolean()); + case NULL -> { + in.nextNull(); + yield new DataElement.Null(); + } + case BEGIN_ARRAY -> in.array(b -> { + DataElement.Array array = new DataElement.Array(); + while (b.hasNext()) { + array.elements().add(deserialize(b)); + } + return array; + }); + case BEGIN_OBJECT -> in.object(b -> { + DataElement.Object object = new DataElement.Object(); + while (b.hasNext()) { + object.members().put(b.nextName(), deserialize(b)); + } + return object; + }); + case END_ARRAY, END_OBJECT, END_DOCUMENT, NAME -> throw new IllegalStateException(); + }; + } +} diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/EmulatedReader.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/EmulatedReader.java new file mode 100644 index 0000000..0da4ca1 --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/EmulatedReader.java @@ -0,0 +1,281 @@ +package io.gitlab.jfronny.commons.serialize.stream.emulated; + +import io.gitlab.jfronny.commons.serialize.stream.MalformedDataException; +import io.gitlab.jfronny.commons.serialize.stream.SerializeReader; +import io.gitlab.jfronny.commons.serialize.stream.Token; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; + +public class EmulatedReader extends SerializeReader { + private static final Object SENTINEL_CLOSED = new Object(); + + /* + * The nesting stack. Using a manual array rather than an ArrayList saves 20%. + */ + private Object[] stack = new Object[32]; + private int stackSize = 0; + + /* + * The path members. It corresponds directly to stack: At indices where the + * stack contains an object (EMPTY_OBJECT, DANGLING_NAME or NONEMPTY_OBJECT), + * pathNames contains the name at this scope. Where it contains an array + * (EMPTY_ARRAY, NONEMPTY_ARRAY) pathIndices contains the current index in + * that array. Otherwise the value is undefined, and we take advantage of that + * by incrementing pathIndices when doing so isn't useful. + */ + private String[] pathNames = new String[32]; + private int[] pathIndices = new int[32]; + + public EmulatedReader(DataElement element) { + push(element); + } + + private void push(Object newTop) { + if (stackSize == stack.length) { + int newLength = stackSize * 2; + stack = Arrays.copyOf(stack, newLength); + pathIndices = Arrays.copyOf(pathIndices, newLength); + pathNames = Arrays.copyOf(pathNames, newLength); + } + stack[stackSize++] = newTop; + } + + private Object peekStack() { + return stack[stackSize - 1]; + } + + private Object popStack() { + Object result = stack[--stackSize]; + stack[stackSize] = null; + return result; + } + + private void expect(Token expected) throws MalformedDataException { + if (peek() != expected) { + throw new IllegalStateException( + "Expected " + expected + " but was " + peek() + locationString()); + } + } + + private String nextName(boolean skipName) throws MalformedDataException { + expect(Token.NAME); + Iterator i = (Iterator) peekStack(); + Map.Entry entry = (Map.Entry) i.next(); + String result = (String) entry.getKey(); + pathNames[stackSize - 1] = skipName ? "" : result; + push(entry.getValue()); + return result; + } + + @Override + public EmulatedReader beginArray() throws MalformedDataException { + expect(Token.BEGIN_ARRAY); + DataElement.Array array = (DataElement.Array) peekStack(); + push(array.elements().iterator()); + pathIndices[stackSize - 1] = 0; + return this; + } + + @Override + public EmulatedReader endArray() throws MalformedDataException { + expect(Token.END_ARRAY); + popStack(); // empty iterator + popStack(); // array + if (stackSize > 0) { + pathIndices[stackSize - 1]++; + } + return this; + } + + @Override + public EmulatedReader beginObject() throws MalformedDataException { + expect(Token.BEGIN_OBJECT); + DataElement.Object object = (DataElement.Object) peekStack(); + push(object.members().entrySet().iterator()); + return this; + } + + @Override + public EmulatedReader endObject() throws MalformedDataException { + expect(Token.END_OBJECT); + pathNames[stackSize - 1] = null; // Free the last path name so that it can be garbage collected + popStack(); // empty iterator + popStack(); // object + if (stackSize > 0) { + pathIndices[stackSize - 1]++; + } + return this; + } + + @Override + public boolean hasNext() throws MalformedDataException { + Token token = peek(); + return token != Token.END_OBJECT + && token != Token.END_ARRAY + && token != Token.END_DOCUMENT; + } + + @Override + public Token peek() throws MalformedDataException { + if (stackSize == 0) { + return Token.END_DOCUMENT; + } + + Object o = peekStack(); + if (o instanceof Iterator) { + boolean isObject = stack[stackSize - 2] instanceof DataElement.Object; + Iterator iterator = (Iterator) o; + if (iterator.hasNext()) { + if (isObject) { + return Token.NAME; + } else { + push(iterator.next()); + return peek(); + } + } else { + return isObject ? Token.END_OBJECT : Token.END_ARRAY; + } + } else if (o instanceof DataElement e) { + return switch (e) { + case DataElement.Object l -> Token.BEGIN_OBJECT; + case DataElement.Array l -> Token.BEGIN_ARRAY; + case DataElement.Primitive p -> switch (p) { + case DataElement.Primitive.String s -> Token.STRING; + case DataElement.Primitive.Boolean b -> Token.BOOLEAN; + case DataElement.Primitive.Number n -> Token.NUMBER; + }; + case DataElement.Null l -> Token.NULL; + }; + } else if (o == SENTINEL_CLOSED) { + throw new IllegalStateException("JsonReader is closed"); + } else { + throw new MalformedDataException("Custom JsonElement subclass " + o.getClass().getName() + " is not supported"); + } + } + + @Override + public String nextName() throws MalformedDataException { + return nextName(false); + } + + @Override + public String nextString() throws MalformedDataException { + Token token = peek(); + if (token != Token.STRING && token != Token.NUMBER) { + throw new IllegalStateException( + "Expected " + Token.STRING + " but was " + token + locationString()); + } + String result = ((DataElement.Primitive) popStack()).asString(); + if (stackSize > 0) { + pathIndices[stackSize - 1]++; + } + return result; + } + + @Override + public boolean nextBoolean() throws MalformedDataException { + expect(Token.BOOLEAN); + if (!(popStack() instanceof DataElement.Primitive.Boolean result)) { + throw new IllegalStateException("Expected a boolean"); + } + if (stackSize > 0) { + pathIndices[stackSize - 1]++; + } + return result.value(); + } + + @Override + public void nextNull() throws MalformedDataException { + expect(Token.NULL); + popStack(); + if (stackSize > 0) { + pathIndices[stackSize - 1]++; + } + } + + @Override + public Number nextNumber() throws MalformedDataException { + Token token = peek(); + if (token != Token.NUMBER && token != Token.STRING) { + throw new IllegalStateException( + "Expected " + Token.NUMBER + " but was " + token + locationString()); + } + if (!(popStack() instanceof DataElement.Primitive.Number result)) { + throw new IllegalStateException("Expected a number"); + } + popStack(); + if (stackSize > 0) { + pathIndices[stackSize - 1]++; + } + return result.value(); + } + + @Override + public void skipValue() throws MalformedDataException { + Token peeked = peek(); + switch (peeked) { + case NAME: + @SuppressWarnings("unused") + String unused = nextName(true); + break; + case END_ARRAY: + endArray(); + throw new IllegalStateException("Attempt to skip led outside its parent"); + case END_OBJECT: + endObject(); + throw new IllegalStateException("Attempt to skip led outside its parent"); + case END_DOCUMENT: + throw new IllegalStateException("Attempt to skip led outside the document"); + default: + popStack(); + if (stackSize > 0) { + pathIndices[stackSize - 1]++; + } + break; + } + } + + private String getPath(boolean usePreviousPath) { + StringBuilder result = new StringBuilder().append('$'); + for (int i = 0; i < stackSize; i++) { + if (stack[i] instanceof DataElement.Array) { + if (++i < stackSize && stack[i] instanceof Iterator) { + int pathIndex = pathIndices[i]; + // If index is last path element it points to next array element; have to decrement + // `- 1` covers case where iterator for next element is on stack + // `- 2` covers case where peek() already pushed next element onto stack + if (usePreviousPath && pathIndex > 0 && (i == stackSize - 1 || i == stackSize - 2)) { + pathIndex--; + } + result.append('[').append(pathIndex).append(']'); + } + } else if (stack[i] instanceof DataElement.Object) { + if (++i < stackSize && stack[i] instanceof Iterator) { + result.append('.'); + if (pathNames[i] != null) { + result.append(pathNames[i]); + } + } + } + } + return result.toString(); + } + + @Override + public String getPath() { + return getPath(false); + } + + @Override + public String getPreviousPath() { + return getPath(true); + } + + @Override + public void close() throws Exception { + stack = new Object[] {SENTINEL_CLOSED}; + stackSize = 1; + } +} diff --git a/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/EmulatedWriter.java b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/EmulatedWriter.java new file mode 100644 index 0000000..40c19d5 --- /dev/null +++ b/commons-serialize/src/main/java/io/gitlab/jfronny/commons/serialize/stream/emulated/EmulatedWriter.java @@ -0,0 +1,186 @@ +package io.gitlab.jfronny.commons.serialize.stream.emulated; + +import io.gitlab.jfronny.commons.serialize.stream.SerializeWriter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class EmulatedWriter extends SerializeWriter { + /** Added to the top of the stack when this writer is closed to cause following ops to fail. */ + private static final DataElement.Primitive.String SENTINEL_CLOSED = new DataElement.Primitive.String("closed"); + + /** The JsonElements and JsonArrays under modification, outermost to innermost. */ + private final List stack = new ArrayList<>(); + + /** The name for the next JSON object value. If non-null, the top of the stack is a JsonObject. */ + private String pendingName; + + /** the JSON element constructed by this writer. */ + private DataElement product = new DataElement.Null(); + + public DataElement get() { + if (!stack.isEmpty()) { + throw new IllegalStateException("Expected one JSON element but was " + stack); + } + return product; + } + + private void put(DataElement value) { + if (pendingName != null) { + if (!(value instanceof DataElement.Null) || serializeNulls) { + ((DataElement.Object) stack.getLast()).members().put(pendingName, value); + } + pendingName = null; + } else if (stack.isEmpty()) { + product = value; + } else { + DataElement element = stack.getLast(); + if (element instanceof DataElement.Array array) { + array.elements().add(value); + } else { + throw new IllegalStateException(); + } + } + } + + @Override + public EmulatedWriter beginArray() throws RuntimeException { + DataElement.Array array = new DataElement.Array(); + put(array); + stack.add(array); + return this; + } + + @Override + public EmulatedWriter endArray() throws RuntimeException { + if (stack.isEmpty() || pendingName != null) { + throw new IllegalStateException(); + } + DataElement element = stack.getLast(); + if (element instanceof DataElement.Array) { + stack.removeLast(); + return this; + } + throw new IllegalStateException(); + } + + @Override + public EmulatedWriter beginObject() throws RuntimeException { + DataElement.Object object = new DataElement.Object(); + put(object); + stack.add(object); + return this; + } + + @Override + public EmulatedWriter endObject() throws RuntimeException { + if (stack.isEmpty() || pendingName != null) { + throw new IllegalStateException(); + } + DataElement element = stack.getLast(); + if (element instanceof DataElement.Object) { + stack.removeLast(); + return this; + } + throw new IllegalStateException(); + } + + @Override + public EmulatedWriter comment(String comment) throws RuntimeException { + return this; + } + + @Override + public EmulatedWriter name(String name) throws RuntimeException { + Objects.requireNonNull(name, "name == null"); + if (stack.isEmpty() || pendingName != null) { + throw new IllegalStateException("Did not expect a name"); + } + DataElement element = stack.getLast(); + if (element instanceof DataElement.Object) { + pendingName = name; + return this; + } + throw new IllegalStateException("Please begin an object before writing a name."); + } + + @Override + public EmulatedWriter value(String value) throws RuntimeException { + if (value == null) { + return nullValue(); + } + put(new DataElement.Primitive.String(value)); + return this; + } + + @Override + public EmulatedWriter value(boolean value) throws RuntimeException { + put(new DataElement.Primitive.Boolean(value)); + return this; + } + + @Override + public EmulatedWriter value(float value) throws RuntimeException { + if (!serializeSpecialFloatingPointValues && (Float.isNaN(value) || Float.isInfinite(value))) { + throw new IllegalArgumentException("NaN and infinities are not permitted in this writer: " + value); + } + put(new DataElement.Primitive.Number(value)); + return this; + } + + @Override + public EmulatedWriter value(double value) throws RuntimeException { + if (!serializeSpecialFloatingPointValues && (Double.isNaN(value) || Double.isInfinite(value))) { + throw new IllegalArgumentException("NaN and infinities are not permitted in this writer: " + value); + } + put(new DataElement.Primitive.Number(value)); + return this; + } + + @Override + public EmulatedWriter value(long value) throws RuntimeException { + put(new DataElement.Primitive.Number(value)); + return this; + } + + @Override + public EmulatedWriter value(Number value) throws RuntimeException { + if (value == null) { + return nullValue(); + } + + if (!isLenient()) { + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + throw new IllegalArgumentException("NaN and infinities are not permitted in this writer: " + value); + } + } + + put(new DataElement.Primitive.Number(value)); + return this; + } + + @Override + public EmulatedWriter nullValue() throws RuntimeException { + put(new DataElement.Null()); + return this; + } + + @Override + public EmulatedWriter literalValue(String value) throws RuntimeException { + throw new UnsupportedOperationException(); + } + + @Override + public void flush() throws IOException {} + + @Override + public void close() throws Exception { + if (!stack.isEmpty()) { + throw new IOException("Incomplete document"); + } + stack.add(SENTINEL_CLOSED); + } +} diff --git a/commons-serialize/src/main/java/module-info.java b/commons-serialize/src/main/java/module-info.java index 1289d70..556f678 100644 --- a/commons-serialize/src/main/java/module-info.java +++ b/commons-serialize/src/main/java/module-info.java @@ -1,4 +1,6 @@ module io.gitlab.jfronny.commons.serialize { requires static org.jetbrains.annotations; + requires io.gitlab.jfronny.commons; exports io.gitlab.jfronny.commons.serialize; + exports io.gitlab.jfronny.commons.serialize.stream; } \ No newline at end of file diff --git a/commons/src/main/java/io/gitlab/jfronny/commons/data/LazilyParsedNumber.java b/commons/src/main/java/io/gitlab/jfronny/commons/data/LazilyParsedNumber.java new file mode 100644 index 0000000..f20d9b2 --- /dev/null +++ b/commons/src/main/java/io/gitlab/jfronny/commons/data/LazilyParsedNumber.java @@ -0,0 +1,72 @@ +package io.gitlab.jfronny.commons.data; + +import java.math.BigDecimal; +import java.util.Objects; + +public class LazilyParsedNumber extends Number { + private final String value; + + public LazilyParsedNumber(String value) { + this.value = value; + } + + private BigDecimal asBigDecimal() { + if (value.length() > 10_000) { + throw new NumberFormatException("Number string too large: " + value.substring(0, 30) + "..."); + } + BigDecimal decimal = new BigDecimal(value); + + // Cast to long to avoid issues with abs when value is Integer.MIN_VALUE + if (Math.abs((long) decimal.scale()) >= 10_000) { + throw new NumberFormatException("Number has unsupported scale: " + value); + } + return decimal; + } + + @Override + public int intValue() { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + try { + return (int) Long.parseLong(value); + } catch (NumberFormatException nfe) { + return asBigDecimal().intValue(); + } + } + } + + @Override + public long longValue() { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return asBigDecimal().longValue(); + } + } + + @Override + public float floatValue() { + return Float.parseFloat(value); + } + + @Override + public double doubleValue() { + return Double.parseDouble(value); + } + + @Override + public String toString() { + return value; + } + + @Override + public boolean equals(Object o) { + return o instanceof LazilyParsedNumber that && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/commons/src/main/java/io/gitlab/jfronny/commons/data/LinkedTreeMap.java b/commons/src/main/java/io/gitlab/jfronny/commons/data/LinkedTreeMap.java new file mode 100644 index 0000000..a3da8c5 --- /dev/null +++ b/commons/src/main/java/io/gitlab/jfronny/commons/data/LinkedTreeMap.java @@ -0,0 +1,649 @@ +package io.gitlab.jfronny.commons.data; + + +import java.io.*; +import java.util.*; + +/** + * A map of comparable keys to values. Unlike {@code TreeMap}, this class uses insertion order for + * iteration order. Comparison order is only used as an optimization for efficient insertion and + * removal. + * + *

This implementation was derived from Android 4.1's TreeMap class. + */ +public class LinkedTreeMap extends AbstractMap implements Serializable { + @SuppressWarnings({"unchecked", "rawtypes"}) // to avoid Comparable>> + private static final Comparator NATURAL_ORDER = + new Comparator() { + @Override + public int compare(Comparable a, Comparable b) { + return a.compareTo(b); + } + }; + + private final Comparator comparator; + private final boolean allowNullValues; + Node root; + int size = 0; + int modCount = 0; + + // Used to preserve iteration order + final Node header; + + /** + * Create a natural order, empty tree map whose keys must be mutually comparable and non-null, and + * whose values can be {@code null}. + */ + @SuppressWarnings("unchecked") // unsafe! this assumes K is comparable + public LinkedTreeMap() { + this((Comparator) NATURAL_ORDER, true); + } + + /** + * Create a natural order, empty tree map whose keys must be mutually comparable and non-null. + * + * @param allowNullValues whether {@code null} is allowed as entry value + */ + @SuppressWarnings("unchecked") // unsafe! this assumes K is comparable + public LinkedTreeMap(boolean allowNullValues) { + this((Comparator) NATURAL_ORDER, allowNullValues); + } + + /** + * Create a tree map ordered by {@code comparator}. This map's keys may only be null if {@code + * comparator} permits. + * + * @param comparator the comparator to order elements with, or {@code null} to use the natural + * ordering. + * @param allowNullValues whether {@code null} is allowed as entry value + */ + // unsafe! if comparator is null, this assumes K is comparable + @SuppressWarnings({"unchecked", "rawtypes"}) + public LinkedTreeMap(Comparator comparator, boolean allowNullValues) { + this.comparator = comparator != null ? comparator : (Comparator) NATURAL_ORDER; + this.allowNullValues = allowNullValues; + this.header = new Node<>(allowNullValues); + } + + @Override + public int size() { + return size; + } + + @Override + public V get(Object key) { + Node node = findByObject(key); + return node != null ? node.value : null; + } + + @Override + public boolean containsKey(Object key) { + return findByObject(key) != null; + } + + @Override + public V put(K key, V value) { + if (key == null) { + throw new NullPointerException("key == null"); + } + if (value == null && !allowNullValues) { + throw new NullPointerException("value == null"); + } + Node created = find(key, true); + V result = created.value; + created.value = value; + return result; + } + + @Override + public void clear() { + root = null; + size = 0; + modCount++; + + // Clear iteration order + Node header = this.header; + header.next = header.prev = header; + } + + @Override + public V remove(Object key) { + Node node = removeInternalByKey(key); + return node != null ? node.value : null; + } + + /** + * Returns the node at or adjacent to the given key, creating it if requested. + * + * @throws ClassCastException if {@code key} and the tree's keys aren't mutually comparable. + */ + Node find(K key, boolean create) { + Comparator comparator = this.comparator; + Node nearest = root; + int comparison = 0; + + if (nearest != null) { + // Micro-optimization: avoid polymorphic calls to Comparator.compare(). + @SuppressWarnings("unchecked") // Throws a ClassCastException below if there's trouble. + Comparable comparableKey = + (comparator == NATURAL_ORDER) ? (Comparable) key : null; + + while (true) { + comparison = + (comparableKey != null) + ? comparableKey.compareTo(nearest.key) + : comparator.compare(key, nearest.key); + + // We found the requested key. + if (comparison == 0) { + return nearest; + } + + // If it exists, the key is in a subtree. Go deeper. + Node child = (comparison < 0) ? nearest.left : nearest.right; + if (child == null) { + break; + } + + nearest = child; + } + } + + // The key doesn't exist in this tree. + if (!create) { + return null; + } + + // Create the node and add it to the tree or the table. + Node header = this.header; + Node created; + if (nearest == null) { + // Check that the value is comparable if we didn't do any comparisons. + if (comparator == NATURAL_ORDER && !(key instanceof Comparable)) { + throw new ClassCastException(key.getClass().getName() + " is not Comparable"); + } + created = new Node<>(allowNullValues, nearest, key, header, header.prev); + root = created; + } else { + created = new Node<>(allowNullValues, nearest, key, header, header.prev); + if (comparison < 0) { // nearest.key is higher + nearest.left = created; + } else { // comparison > 0, nearest.key is lower + nearest.right = created; + } + rebalance(nearest, true); + } + size++; + modCount++; + + return created; + } + + @SuppressWarnings("unchecked") + Node findByObject(Object key) { + try { + return key != null ? find((K) key, false) : null; + } catch (ClassCastException e) { + return null; + } + } + + /** + * Returns this map's entry that has the same key and value as {@code entry}, or null if this map + * has no such entry. + * + *

This method uses the comparator for key equality rather than {@code equals}. If this map's + * comparator isn't consistent with equals (such as {@code String.CASE_INSENSITIVE_ORDER}), then + * {@code remove()} and {@code contains()} will violate the collections API. + */ + Node findByEntry(Entry entry) { + Node mine = findByObject(entry.getKey()); + boolean valuesEqual = mine != null && equal(mine.value, entry.getValue()); + return valuesEqual ? mine : null; + } + + private static boolean equal(Object a, Object b) { + return Objects.equals(a, b); + } + + /** + * Removes {@code node} from this tree, rearranging the tree's structure as necessary. + * + * @param unlink true to also unlink this node from the iteration linked list. + */ + void removeInternal(Node node, boolean unlink) { + if (unlink) { + node.prev.next = node.next; + node.next.prev = node.prev; + } + + Node left = node.left; + Node right = node.right; + Node originalParent = node.parent; + if (left != null && right != null) { + + /* + * To remove a node with both left and right subtrees, move an + * adjacent node from one of those subtrees into this node's place. + * + * Removing the adjacent node may change this node's subtrees. This + * node may no longer have two subtrees once the adjacent node is + * gone! + */ + + Node adjacent = (left.height > right.height) ? left.last() : right.first(); + removeInternal(adjacent, false); // takes care of rebalance and size-- + + int leftHeight = 0; + left = node.left; + if (left != null) { + leftHeight = left.height; + adjacent.left = left; + left.parent = adjacent; + node.left = null; + } + + int rightHeight = 0; + right = node.right; + if (right != null) { + rightHeight = right.height; + adjacent.right = right; + right.parent = adjacent; + node.right = null; + } + + adjacent.height = Math.max(leftHeight, rightHeight) + 1; + replaceInParent(node, adjacent); + return; + } else if (left != null) { + replaceInParent(node, left); + node.left = null; + } else if (right != null) { + replaceInParent(node, right); + node.right = null; + } else { + replaceInParent(node, null); + } + + rebalance(originalParent, false); + size--; + modCount++; + } + + Node removeInternalByKey(Object key) { + Node node = findByObject(key); + if (node != null) { + removeInternal(node, true); + } + return node; + } + + @SuppressWarnings("ReferenceEquality") + private void replaceInParent(Node node, Node replacement) { + Node parent = node.parent; + node.parent = null; + if (replacement != null) { + replacement.parent = parent; + } + + if (parent != null) { + if (parent.left == node) { + parent.left = replacement; + } else { + assert parent.right == node; + parent.right = replacement; + } + } else { + root = replacement; + } + } + + /** + * Rebalances the tree by making any AVL rotations necessary between the newly-unbalanced node and + * the tree's root. + * + * @param insert true if the node was unbalanced by an insert; false if it was by a removal. + */ + private void rebalance(Node unbalanced, boolean insert) { + for (Node node = unbalanced; node != null; node = node.parent) { + Node left = node.left; + Node right = node.right; + int leftHeight = left != null ? left.height : 0; + int rightHeight = right != null ? right.height : 0; + + int delta = leftHeight - rightHeight; + if (delta == -2) { + Node rightLeft = right.left; + Node rightRight = right.right; + int rightRightHeight = rightRight != null ? rightRight.height : 0; + int rightLeftHeight = rightLeft != null ? rightLeft.height : 0; + + int rightDelta = rightLeftHeight - rightRightHeight; + if (rightDelta == -1 || (rightDelta == 0 && !insert)) { + rotateLeft(node); // AVL right right + } else { + assert (rightDelta == 1); + rotateRight(right); // AVL right left + rotateLeft(node); + } + if (insert) { + break; // no further rotations will be necessary + } + + } else if (delta == 2) { + Node leftLeft = left.left; + Node leftRight = left.right; + int leftRightHeight = leftRight != null ? leftRight.height : 0; + int leftLeftHeight = leftLeft != null ? leftLeft.height : 0; + + int leftDelta = leftLeftHeight - leftRightHeight; + if (leftDelta == 1 || (leftDelta == 0 && !insert)) { + rotateRight(node); // AVL left left + } else { + assert (leftDelta == -1); + rotateLeft(left); // AVL left right + rotateRight(node); + } + if (insert) { + break; // no further rotations will be necessary + } + + } else if (delta == 0) { + node.height = leftHeight + 1; // leftHeight == rightHeight + if (insert) { + break; // the insert caused balance, so rebalancing is done! + } + + } else { + assert (delta == -1 || delta == 1); + node.height = Math.max(leftHeight, rightHeight) + 1; + if (!insert) { + break; // the height hasn't changed, so rebalancing is done! + } + } + } + } + + /** Rotates the subtree so that its root's right child is the new root. */ + private void rotateLeft(Node root) { + Node left = root.left; + Node pivot = root.right; + Node pivotLeft = pivot.left; + Node pivotRight = pivot.right; + + // move the pivot's left child to the root's right + root.right = pivotLeft; + if (pivotLeft != null) { + pivotLeft.parent = root; + } + + replaceInParent(root, pivot); + + // move the root to the pivot's left + pivot.left = root; + root.parent = pivot; + + // fix heights + root.height = + Math.max(left != null ? left.height : 0, pivotLeft != null ? pivotLeft.height : 0) + 1; + pivot.height = Math.max(root.height, pivotRight != null ? pivotRight.height : 0) + 1; + } + + /** Rotates the subtree so that its root's left child is the new root. */ + private void rotateRight(Node root) { + Node pivot = root.left; + Node right = root.right; + Node pivotLeft = pivot.left; + Node pivotRight = pivot.right; + + // move the pivot's right child to the root's left + root.left = pivotRight; + if (pivotRight != null) { + pivotRight.parent = root; + } + + replaceInParent(root, pivot); + + // move the root to the pivot's right + pivot.right = root; + root.parent = pivot; + + // fixup heights + root.height = + Math.max(right != null ? right.height : 0, pivotRight != null ? pivotRight.height : 0) + 1; + pivot.height = Math.max(root.height, pivotLeft != null ? pivotLeft.height : 0) + 1; + } + + private EntrySet entrySet; + private KeySet keySet; + + @Override + public Set> entrySet() { + EntrySet result = entrySet; + return result != null ? result : (entrySet = new EntrySet()); + } + + @Override + public Set keySet() { + KeySet result = keySet; + return result != null ? result : (keySet = new KeySet()); + } + + static final class Node implements Entry { + Node parent; + Node left; + Node right; + Node next; + Node prev; + final K key; + final boolean allowNullValue; + V value; + int height; + + /** Create the header entry */ + Node(boolean allowNullValue) { + key = null; + this.allowNullValue = allowNullValue; + next = prev = this; + } + + /** Create a regular entry */ + Node(boolean allowNullValue, Node parent, K key, Node next, Node prev) { + this.parent = parent; + this.key = key; + this.allowNullValue = allowNullValue; + this.height = 1; + this.next = next; + this.prev = prev; + prev.next = this; + next.prev = this; + } + + @Override + public K getKey() { + return key; + } + + @Override + public V getValue() { + return value; + } + + @Override + public V setValue(V value) { + if (value == null && !allowNullValue) { + throw new NullPointerException("value == null"); + } + V oldValue = this.value; + this.value = value; + return oldValue; + } + + @Override + public boolean equals(Object o) { + if (o instanceof Entry) { + Entry other = (Entry) o; + return (key == null ? other.getKey() == null : key.equals(other.getKey())) + && (value == null ? other.getValue() == null : value.equals(other.getValue())); + } + return false; + } + + @Override + public int hashCode() { + return (key == null ? 0 : key.hashCode()) ^ (value == null ? 0 : value.hashCode()); + } + + @Override + public String toString() { + return key + "=" + value; + } + + /** Returns the first node in this subtree. */ + public Node first() { + Node node = this; + Node child = node.left; + while (child != null) { + node = child; + child = node.left; + } + return node; + } + + /** Returns the last node in this subtree. */ + public Node last() { + Node node = this; + Node child = node.right; + while (child != null) { + node = child; + child = node.right; + } + return node; + } + } + + private abstract class LinkedTreeMapIterator implements Iterator { + Node next = header.next; + Node lastReturned = null; + int expectedModCount = modCount; + + LinkedTreeMapIterator() {} + + @Override + @SuppressWarnings("ReferenceEquality") + public final boolean hasNext() { + return next != header; + } + + @SuppressWarnings("ReferenceEquality") + final Node nextNode() { + Node e = next; + if (e == header) { + throw new NoSuchElementException(); + } + if (modCount != expectedModCount) { + throw new ConcurrentModificationException(); + } + next = e.next; + return lastReturned = e; + } + + @Override + public final void remove() { + if (lastReturned == null) { + throw new IllegalStateException(); + } + removeInternal(lastReturned, true); + lastReturned = null; + expectedModCount = modCount; + } + } + + class EntrySet extends AbstractSet> { + @Override + public int size() { + return size; + } + + @Override + public Iterator> iterator() { + return new LinkedTreeMapIterator>() { + @Override + public Entry next() { + return nextNode(); + } + }; + } + + @Override + public boolean contains(Object o) { + return o instanceof Entry && findByEntry((Entry) o) != null; + } + + @Override + public boolean remove(Object o) { + if (!(o instanceof Entry)) { + return false; + } + + Node node = findByEntry((Entry) o); + if (node == null) { + return false; + } + removeInternal(node, true); + return true; + } + + @Override + public void clear() { + LinkedTreeMap.this.clear(); + } + } + + final class KeySet extends AbstractSet { + @Override + public int size() { + return size; + } + + @Override + public Iterator iterator() { + return new LinkedTreeMapIterator() { + @Override + public K next() { + return nextNode().key; + } + }; + } + + @Override + public boolean contains(Object o) { + return containsKey(o); + } + + @Override + public boolean remove(Object key) { + return removeInternalByKey(key) != null; + } + + @Override + public void clear() { + LinkedTreeMap.this.clear(); + } + } + + /** + * If somebody is unlucky enough to have to serialize one of these, serialize it as a + * LinkedHashMap so that they won't need Gson on the other side to deserialize it. Using + * serialization defeats our DoS defence, so most apps shouldn't use it. + */ + private Object writeReplace() throws ObjectStreamException { + return new LinkedHashMap<>(this); + } + + private void readObject(ObjectInputStream in) throws IOException { + // Don't permit directly deserializing this class; writeReplace() should have written a + // replacement + throw new InvalidObjectException("Deserialization is unsupported"); + } +} diff --git a/commons/src/test/java/io/gitlab/jfronny/commons/test/LinkedTreeMapTest.java b/commons/src/test/java/io/gitlab/jfronny/commons/test/LinkedTreeMapTest.java new file mode 100644 index 0000000..e5cbe82 --- /dev/null +++ b/commons/src/test/java/io/gitlab/jfronny/commons/test/LinkedTreeMapTest.java @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.gitlab.jfronny.commons.test; + +import io.gitlab.jfronny.commons.data.LinkedTreeMap; +import org.junit.jupiter.api.Test; + +import java.io.*; +import java.util.*; +import java.util.Map.Entry; + +import static org.junit.jupiter.api.Assertions.*; + +public final class LinkedTreeMapTest { + + @Test + public void testIterationOrder() { + LinkedTreeMap map = new LinkedTreeMap<>(); + map.put("a", "android"); + map.put("c", "cola"); + map.put("b", "bbq"); + assertIterationOrder(map.keySet(), "a", "c", "b"); + assertIterationOrder(map.values(), "android", "cola", "bbq"); + } + + @Test + public void testRemoveRootDoesNotDoubleUnlink() { + LinkedTreeMap map = new LinkedTreeMap<>(); + map.put("a", "android"); + map.put("c", "cola"); + map.put("b", "bbq"); + Iterator> it = map.entrySet().iterator(); + it.next(); + it.next(); + it.next(); + it.remove(); + assertIterationOrder(map.keySet(), "a", "c"); + } + + @Test + @SuppressWarnings("ModifiedButNotUsed") + public void testPutNullKeyFails() { + LinkedTreeMap map = new LinkedTreeMap<>(); + try { + map.put(null, "android"); + fail(); + } catch (NullPointerException expected) { + } + } + + @Test + @SuppressWarnings("ModifiedButNotUsed") + public void testPutNonComparableKeyFails() { + LinkedTreeMap map = new LinkedTreeMap<>(); + try { + map.put(new Object(), "android"); + fail(); + } catch (ClassCastException expected) { + } + } + + @Test + public void testPutNullValue() { + LinkedTreeMap map = new LinkedTreeMap<>(); + map.put("a", null); + + assertEquals(1, map.size()); + assertTrue(map.containsKey("a")); + assertTrue(map.containsValue(null)); + assertNull(map.get("a")); + } + + @Test + public void testPutNullValue_Forbidden() { + LinkedTreeMap map = new LinkedTreeMap<>(false); + try { + map.put("a", null); + fail(); + } catch (NullPointerException e) { + assertEquals("value == null", e.getMessage()); + } + assertEquals(0, map.size()); + assertFalse(map.containsKey("a")); + assertFalse(map.containsValue(null)); + } + + @Test + public void testEntrySetValueNull() { + LinkedTreeMap map = new LinkedTreeMap<>(); + map.put("a", "1"); + assertEquals("1", map.get("a")); + Entry entry = map.entrySet().iterator().next(); + assertEquals("a", entry.getKey()); + assertEquals("1", entry.getValue()); + entry.setValue(null); + assertNull(entry.getValue()); + + assertTrue(map.containsKey("a")); + assertTrue(map.containsValue(null)); + assertNull(map.get("a")); + } + + @Test + public void testEntrySetValueNull_Forbidden() { + LinkedTreeMap map = new LinkedTreeMap<>(false); + map.put("a", "1"); + Entry entry = map.entrySet().iterator().next(); + try { + entry.setValue(null); + fail(); + } catch (NullPointerException e) { + assertEquals("value == null", e.getMessage()); + } + assertEquals("1", entry.getValue()); + assertEquals("1", map.get("a")); + assertFalse(map.containsValue(null)); + } + + @Test + public void testContainsNonComparableKeyReturnsFalse() { + LinkedTreeMap map = new LinkedTreeMap<>(); + map.put("a", "android"); + assertFalse(map.containsKey(new Object())); + } + + @Test + public void testContainsNullKeyIsAlwaysFalse() { + LinkedTreeMap map = new LinkedTreeMap<>(); + assertFalse(map.containsKey(null)); + map.put("a", "android"); + assertFalse(map.containsKey(null)); + } + + @Test + public void testPutOverrides() throws Exception { + LinkedTreeMap map = new LinkedTreeMap<>(); + assertNull(map.put("d", "donut")); + assertNull(map.put("e", "eclair")); + assertNull(map.put("f", "froyo")); + assertEquals(3, map.size()); + + assertEquals("donut", map.get("d")); + assertEquals("donut", map.put("d", "done")); + assertEquals(3, map.size()); + } + + @Test + public void testEmptyStringValues() { + LinkedTreeMap map = new LinkedTreeMap<>(); + map.put("a", ""); + assertTrue(map.containsKey("a")); + assertEquals("", map.get("a")); + } + + @Test + public void testLargeSetOfRandomKeys() { + Random random = new Random(1367593214724L); + LinkedTreeMap map = new LinkedTreeMap<>(); + String[] keys = new String[1000]; + for (int i = 0; i < keys.length; i++) { + keys[i] = Integer.toString(random.nextInt(), 36) + "-" + i; + map.put(keys[i], "" + i); + } + + for (int i = 0; i < keys.length; i++) { + String key = keys[i]; + assertTrue(map.containsKey(key)); + assertEquals("" + i, map.get(key)); + } + } + + @Test + public void testClear() { + LinkedTreeMap map = new LinkedTreeMap<>(); + map.put("a", "android"); + map.put("c", "cola"); + map.put("b", "bbq"); + map.clear(); + assertIterationOrder(map.keySet()); + assertEquals(0, map.size()); + } + + @Test + public void testEqualsAndHashCode() throws Exception { + LinkedTreeMap map1 = new LinkedTreeMap<>(); + map1.put("A", 1); + map1.put("B", 2); + map1.put("C", 3); + map1.put("D", 4); + + LinkedTreeMap map2 = new LinkedTreeMap<>(); + map2.put("C", 3); + map2.put("B", 2); + map2.put("D", 4); + map2.put("A", 1); + + assertEquals(map1, map2); + assertEquals(map1.hashCode(), map2.hashCode()); + } + + @Test + public void testJavaSerialization() throws IOException, ClassNotFoundException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ObjectOutputStream objOut = new ObjectOutputStream(out); + Map map = new LinkedTreeMap<>(); + map.put("a", 1); + objOut.writeObject(map); + objOut.close(); + + ObjectInputStream objIn = new ObjectInputStream(new ByteArrayInputStream(out.toByteArray())); + @SuppressWarnings("unchecked") + Map deserialized = (Map) objIn.readObject(); + assertEquals(Collections.singletonMap("a", 1), deserialized); + } + + @SuppressWarnings("varargs") + @SafeVarargs + private static final void assertIterationOrder(Iterable actual, T... expected) { + ArrayList actualList = new ArrayList<>(); + for (T t : actual) { + actualList.add(t); + } + assertEquals(Arrays.asList(expected), actualList); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6176d2..8e7a08e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,12 +4,15 @@ annotations = "24.1.0" gradle-kotlin-dsl = "4.3.0" jf-scripts = "1.5-SNAPSHOT" gson = "2.10.3-SNAPSHOT" +google-truth = "1.4.2" [libraries] junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } +junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit" } annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" } gson = { module = "io.gitlab.jfronny:gson", version.ref = "gson" } +google-truth = { module = "com.google.truth:truth", version.ref = "google-truth" } plugin-kotlin = { module = "org.gradle.kotlin:gradle-kotlin-dsl-plugins", version.ref = "gradle-kotlin-dsl"} plugin-convention = { module = "io.gitlab.jfronny:convention", version.ref="jf-scripts" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index d37e96b..65dffee 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "JfCommons" include("commons") include("commons-serialize") include("commons-serialize-gson") +include("commons-serialize-json") include("commons-serialize-gson-dsl") include("commons-io") include("commons-logger")