Created
March 6, 2020 09:45
-
-
Save vy/a8018bbdf5442e998f95008f5959e775 to your computer and use it in GitHub Desktop.
Single-file simple JSON parser for Java 8
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright 2020 Volkan Yazıcı | |
* | |
* 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 permits and | |
* limitations under the License. | |
*/ | |
import java.math.BigDecimal; | |
import java.math.BigInteger; | |
import java.text.CharacterIterator; | |
import java.text.StringCharacterIterator; | |
import java.util.LinkedHashMap; | |
import java.util.LinkedList; | |
import java.util.List; | |
import java.util.Map; | |
/** | |
* A simple JSON parser mapping tokens to basic Java types. | |
* <p> | |
* The type mapping is as follows: | |
* <ul> | |
* <li><tt>object</tt>s are mapped to {@link LinkedHashMap LinkedHashMap<String,Object>} | |
* <li><tt>array</tt>s are mapped to {@link LinkedList} | |
* <li><tt>string</tt>s are mapped to {@link String} with proper Unicode and | |
* escape character conversion | |
* <li><tt>true</tt>, <tt>false</tt>, and <tt>null</tt> are mapped to their Java | |
* counterparts | |
* <li>floating point <tt>number</tt>s are mapped to {@link BigDecimal} | |
* <li>integral <tt>number</tt>s are mapped to either primitive types | |
* (<tt>int</tt>, <tt>long</tt>) or {@link BigInteger} | |
* </ul> | |
* <p> | |
* This code is heavily influenced by the reader of | |
* <a href="https://github.com/bolerio/mjson/blob/e7a4da2daa6e17a63ec057948bc30818e8f44686/src/java/mjson/Json.java#L2684">mjson</a>. | |
*/ | |
public final class JsonReader { | |
private enum Delimiter { | |
OBJECT_START("{"), | |
OBJECT_END("}"), | |
ARRAY_START("["), | |
ARRAY_END("]"), | |
COLON(":"), | |
COMMA(","); | |
private final String string; | |
Delimiter(final String string) { | |
this.string = string; | |
} | |
private static boolean exists(final Object token) { | |
for (Delimiter delimiter : values()) { | |
if (delimiter.string.equals(token)) { | |
return true; | |
} | |
} | |
return false; | |
} | |
} | |
private CharacterIterator it; | |
private int readCharIndex = -1; | |
private char readChar; | |
private int readTokenStartIndex = -1; | |
private Object readToken; | |
private StringBuilder buffer = new StringBuilder(); | |
private JsonReader() {} | |
public static Object read(final String string) { | |
final JsonReader reader = new JsonReader(); | |
return reader.read(new StringCharacterIterator(string)); | |
} | |
private Object read(final CharacterIterator ci) { | |
it = ci; | |
readCharIndex = 0; | |
readChar = it.first(); | |
final Object token = readToken(); | |
if (token instanceof Delimiter) { | |
final String message = String.format( | |
"was not expecting %s at index %d", | |
readToken, readTokenStartIndex); | |
throw new IllegalArgumentException(message); | |
} | |
skipWhiteSpace(); | |
if (it.getIndex() != it.getEndIndex()) { | |
final String message = String.format( | |
"was not expecting input at index %d: %c", | |
readCharIndex, readChar); | |
throw new IllegalArgumentException(message); | |
} | |
return token; | |
} | |
private Object readToken() { | |
skipWhiteSpace(); | |
readTokenStartIndex = readCharIndex; | |
final char prevChar = readChar; | |
readChar(); | |
switch (prevChar) { | |
case '"': | |
readToken = readString(); | |
break; | |
case '[': | |
readToken = readArray(); | |
break; | |
case ']': | |
readToken = Delimiter.ARRAY_END; | |
break; | |
case ',': | |
readToken = Delimiter.COMMA; | |
break; | |
case '{': | |
readToken = readObject(); | |
break; | |
case '}': | |
readToken = Delimiter.OBJECT_END; | |
break; | |
case ':': | |
readToken = Delimiter.COLON; | |
break; | |
case 't': | |
readToken = readTrue(); | |
break; | |
case 'f': | |
readToken = readFalse(); | |
break; | |
case 'n': | |
readToken = readNull(); | |
break; | |
default: | |
unreadChar(); | |
if (Character.isDigit(readChar) || readChar == '-') { | |
readToken = readNumber(); | |
} else { | |
String message = String.format( | |
"invalid character at index %d: %c", | |
readCharIndex, readChar); | |
throw new IllegalArgumentException(message); | |
} | |
} | |
return readToken; | |
} | |
private void skipWhiteSpace() { | |
do { | |
if (!Character.isWhitespace(readChar)) { | |
break; | |
} | |
} while (readChar() != CharacterIterator.DONE); | |
} | |
private char readChar() { | |
if (it.getIndex() == it.getEndIndex()) { | |
throw new IllegalArgumentException("premature end of input"); | |
} | |
readChar = it.next(); | |
readCharIndex = it.getIndex(); | |
return readChar; | |
} | |
private void unreadChar() { | |
readChar = it.previous(); | |
readCharIndex = it.getIndex(); | |
} | |
private String readString() { | |
buffer.setLength(0); | |
while (readChar != '"') { | |
if (readChar == '\\') { | |
readChar(); | |
if (readChar == 'u') { | |
final char unicodeChar = readUnicodeChar(); | |
bufferChar(unicodeChar); | |
} else { | |
switch (readChar) { | |
case '"': | |
case '\\': | |
bufferReadChar(); | |
break; | |
case 'b': | |
bufferChar('\b'); | |
break; | |
case 'f': | |
bufferChar('\f'); | |
break; | |
case 'n': | |
bufferChar('\n'); | |
break; | |
case 'r': | |
bufferChar('\r'); | |
break; | |
case 't': | |
bufferChar('\t'); | |
break; | |
default: { | |
final String message = String.format( | |
"was expecting an escape character at index %d: %c", | |
readCharIndex, readChar); | |
throw new IllegalArgumentException(message); | |
} | |
} | |
} | |
} else { | |
bufferReadChar(); | |
} | |
} | |
readChar(); | |
return buffer.toString(); | |
} | |
private void bufferReadChar() { | |
bufferChar(readChar); | |
} | |
private void bufferChar(final char c) { | |
buffer.append(c); | |
readChar(); | |
} | |
private char readUnicodeChar() { | |
int value = 0; | |
for (int i = 0; i < 4; i++) { | |
readChar(); | |
if (readChar >= '0' && readChar <= '9') { | |
value = (value << 4) + readChar - '0'; | |
} else if (readChar >= 'a' && readChar <= 'f') { | |
value = (value << 4) + (readChar - 'a') + 10; | |
} else if (readChar >= 'A' && readChar <= 'F') { | |
value = (value << 4) + (readChar - 'A') + 10; | |
} else { | |
final String message = String.format( | |
"was expecting a unicode character at index %d: %c", | |
readCharIndex, readChar); | |
throw new IllegalArgumentException(message); | |
} | |
} | |
return (char) value; | |
} | |
private Map<String, Object> readObject() { | |
final Map<String, Object> object = new LinkedHashMap<>(); | |
String key = readObjectKey(); | |
while (readToken != Delimiter.OBJECT_END) { | |
expectDelimiter(Delimiter.COLON, readToken()); | |
if (readToken != Delimiter.OBJECT_END) { | |
Object value = readToken(); | |
object.put(key, value); | |
if (readToken() == Delimiter.COMMA) { | |
key = readObjectKey(); | |
if (key == null || Delimiter.exists(key)) { | |
String message = String.format( | |
"was expecting an object key at index %d: %s", | |
readTokenStartIndex, readToken); | |
throw new IllegalArgumentException(message); | |
} | |
} else { | |
expectDelimiter(Delimiter.OBJECT_END, readToken); | |
} | |
} | |
} | |
return object; | |
} | |
private List<Object> readArray() { | |
final List<Object> array = new LinkedList<>(); | |
readToken(); | |
while (readToken != Delimiter.ARRAY_END) { | |
if (readToken instanceof Delimiter) { | |
final String message = String.format( | |
"was expecting an array element at index %d: %s", | |
readTokenStartIndex, readToken); | |
throw new IllegalArgumentException(message); | |
} | |
array.add(readToken); | |
if (readToken() == Delimiter.COMMA) { | |
if (readToken() == Delimiter.ARRAY_END) { | |
final String message = String.format( | |
"was expecting an array element at index %d: %s", | |
readTokenStartIndex, readToken); | |
throw new IllegalArgumentException(message); | |
} | |
} else { | |
expectDelimiter(Delimiter.ARRAY_END, readToken); | |
} | |
} | |
return array; | |
} | |
private String readObjectKey() { | |
readToken(); | |
if (readToken == Delimiter.OBJECT_END) { | |
return null; | |
} else if (readToken instanceof String) { | |
return (String) readToken; | |
} else { | |
final String message = String.format( | |
"was expecting an object key at index %d: %s", | |
readTokenStartIndex, readToken); | |
throw new IllegalArgumentException(message); | |
} | |
} | |
private void expectDelimiter( | |
final Delimiter expectedDelimiter, | |
final Object actualToken) { | |
if (!expectedDelimiter.equals(actualToken)) { | |
String message = String.format( | |
"was expecting %s at index %d: %s", | |
expectedDelimiter, readTokenStartIndex, actualToken); | |
throw new IllegalArgumentException(message); | |
} | |
} | |
private boolean readTrue() { | |
if (readChar != 'r' || readChar() != 'u' || readChar() != 'e') { | |
String message = String.format( | |
"was expecting keyword 'true' at index %d: %s", | |
readCharIndex, readChar); | |
throw new IllegalArgumentException(message); | |
} | |
readChar(); | |
return true; | |
} | |
private boolean readFalse() { | |
if (readChar != 'a' || readChar() != 'l' || readChar() != 's' || readChar() != 'e') { | |
String message = String.format( | |
"was expecting keyword 'false' at index %d: %s", | |
readCharIndex, readChar); | |
throw new IllegalArgumentException(message); | |
} | |
readChar(); | |
return false; | |
} | |
private Object readNull() { | |
if (readChar != 'u' || readChar() != 'l' || readChar() != 'l') { | |
String message = String.format( | |
"was expecting keyword 'null' at index %d: %s", | |
readCharIndex, readChar); | |
throw new IllegalArgumentException(message); | |
} | |
readChar(); | |
return null; | |
} | |
private Number readNumber() { | |
// Read sign. | |
buffer.setLength(0); | |
if (readChar == '-') { | |
bufferReadChar(); | |
} | |
// Read fraction. | |
boolean floatingPoint = false; | |
bufferDigits(); | |
if (readChar == '.') { | |
bufferReadChar(); | |
bufferDigits(); | |
floatingPoint = true; | |
} | |
// Read exponent. | |
if (readChar == 'e' || readChar == 'E') { | |
floatingPoint = true; | |
bufferReadChar(); | |
if (readChar == '+' || readChar == '-') { | |
bufferReadChar(); | |
} | |
bufferDigits(); | |
} | |
// Convert the read number. | |
final String string = buffer.toString(); | |
if (floatingPoint) { | |
return new BigDecimal(string); | |
} else { | |
final BigInteger bigInteger = new BigInteger(string); | |
try { | |
return bigInteger.intValueExact(); | |
} catch (ArithmeticException ignoredIntOverflow) { | |
try { | |
return bigInteger.longValueExact(); | |
} catch (ArithmeticException ignoredLongOverflow) { | |
return bigInteger; | |
} | |
} | |
} | |
} | |
private void bufferDigits() { | |
boolean found = false; | |
while (Character.isDigit(readChar)) { | |
found = true; | |
bufferReadChar(); | |
} | |
if (!found) { | |
final String message = String.format( | |
"was expecting a digit at index %d: %c", | |
readCharIndex, readChar); | |
throw new IllegalArgumentException(message); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright 2020 Volkan Yazıcı | |
* | |
* 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 permits and | |
* limitations under the License. | |
*/ | |
import org.assertj.core.api.Assertions; | |
import org.junit.Test; | |
import java.math.BigDecimal; | |
import java.math.BigInteger; | |
import java.util.Arrays; | |
import java.util.Collections; | |
import java.util.LinkedHashMap; | |
public class JsonReaderTest { | |
@Test | |
public void test_valid_null() { | |
test("null", null); | |
test("[null, null]", Arrays.asList(null, null)); | |
} | |
@Test | |
public void test_invalid_null() { | |
for (final String json : new String[]{"nuL", "nulL", "nul1"}) { | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessageStartingWith("was expecting keyword 'null' at index"); | |
} | |
} | |
@Test | |
public void test_valid_boolean() { | |
test("true", true); | |
test("false", false); | |
test("[true, false]", Arrays.asList(true, false)); | |
} | |
@Test | |
public void test_invalid_boolean() { | |
for (final String json : new String[]{"tru", "truE", "fals", "falsE"}) { | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessageMatching("^was expecting keyword '(true|false)' at index [0-9]+: .*$"); | |
} | |
} | |
@Test | |
public void test_valid_string() { | |
test("\"\"", ""); | |
test("\" \"", " "); | |
test("\" a\"", " a"); | |
test("\"a \"", "a "); | |
test("\"abc\"", "abc"); | |
test("\"abc\\\"\"", "abc\""); | |
test("\"\\b\\f\\n\\r\\t\"", "\b\f\n\r\t"); | |
} | |
@Test | |
public void test_invalid_string_start() { | |
final String json = "abc\""; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("invalid character at index 0: a"); | |
} | |
@Test | |
public void test_invalid_string_end() { | |
for (final String json : new String[]{"", " ", "\r", "\t", "\"abc"}) { | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("premature end of input"); | |
} | |
} | |
@Test | |
public void test_invalid_string_escape() { | |
for (final String json : new String[]{"\"\\k\"", "\"\\d\""}) { | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessageStartingWith( | |
"was expecting an escape character at index 2: "); | |
} | |
} | |
@Test | |
public void test_invalid_string_concat() { | |
final String json = "\"foo\"\"bar\""; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was not expecting input at index 5: \""); | |
} | |
@Test | |
public void test_valid_unicode_string() { | |
final String json = "\"a\\u00eF4bc\""; | |
Assertions | |
.assertThat(JsonReader.read(json)) | |
.as("json=%s", json) | |
.isEqualTo("a" + (char) (0x00ef) + "4bc"); | |
} | |
@Test | |
public void test_invalid_unicode() { | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read("\"\\u000x\"")) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was expecting a unicode character at index 6: x"); | |
} | |
@Test | |
public void test_valid_integers() { | |
for (final String integer : new String[]{ | |
"0", | |
"1", | |
"" + Long.MAX_VALUE + "" + Long.MAX_VALUE}) { | |
for (final String signedInteger : new String[]{integer, '-' + integer}) { | |
final Object expectedToken = | |
signedInteger.length() < 3 | |
? Integer.parseInt(signedInteger) | |
: new BigInteger(signedInteger); | |
test(signedInteger, expectedToken); | |
} | |
} | |
} | |
@Test | |
public void test_invalid_integers() { | |
for (final String integer : new String[]{ | |
"0-", | |
"1a"}) { | |
for (final String signedInteger : new String[]{integer, '-' + integer}) { | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(signedInteger)) | |
.as("signedInteger=%s", signedInteger) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessageStartingWith("was not expecting input at index"); | |
} | |
} | |
} | |
@Test | |
public void test_valid_decimals() { | |
for (final String decimal : new String[]{ | |
"0.0", | |
"1.0", | |
"1.2", | |
"1e2", | |
"1e-2", | |
"1.2e3", | |
"1.2e-3"}) { | |
for (final String signedDecimal : new String[]{decimal, '-' + decimal}) { | |
test(signedDecimal, new BigDecimal(signedDecimal)); | |
} | |
} | |
} | |
@Test | |
public void test_invalid_decimals() { | |
for (final String decimal : new String[]{ | |
"0.", | |
".1", | |
"1e", | |
"1e-", | |
"1.2e", | |
"1.2e-"}) { | |
for (final String signedDecimal : new String[]{decimal, '-' + decimal}) { | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(signedDecimal)) | |
.as("signedDecimal=%s", signedDecimal) | |
.isInstanceOf(IllegalArgumentException.class); | |
} | |
} | |
} | |
@Test | |
public void test_valid_arrays() { | |
for (final String json : new String[]{ | |
"[]", | |
"[ ]"}) { | |
test(json, Collections.emptyList()); | |
} | |
for (final String json : new String[]{ | |
"[1]", | |
"[ 1]", | |
"[1 ]", | |
"[ 1 ]"}) { | |
test(json, Collections.singletonList(1)); | |
} | |
for (final String json : new String[]{ | |
"[1,2]", | |
"[1, 2]", | |
"[ 1, 2]", | |
"[1 , 2]", | |
"[ 1 , 2 ]"}) { | |
test(json, Arrays.asList(1, 2)); | |
} | |
} | |
@Test | |
public void test_invalid_array_start() { | |
final String json = "["; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("premature end of input"); | |
} | |
@Test | |
public void test_invalid_array_end_1() { | |
final String json = "]"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was not expecting ARRAY_END at index 0"); | |
} | |
@Test | |
public void test_invalid_array_comma() { | |
final String json = "[,"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was expecting an array element at index 1: COMMA"); | |
} | |
@Test | |
public void test_invalid_array_end_2() { | |
final String json = "[1,"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("premature end of input"); | |
} | |
@Test | |
public void test_invalid_array_end_3() { | |
final String json = "[1,]"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was expecting an array element at index 3: ARRAY_END"); | |
} | |
@Test | |
public void test_valid_objects() { | |
test("{}", Collections.emptyMap()); | |
test("{\"foo\":\"bar\"}", Collections.singletonMap("foo", "bar")); | |
} | |
@Test | |
public void test_invalid_object_start() { | |
final String json = "{"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("premature end of input"); | |
} | |
@Test | |
public void test_invalid_object_end() { | |
final String json = "}"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was not expecting OBJECT_END at index 0"); | |
} | |
@Test | |
public void test_invalid_object_colon_1() { | |
final String json = "{\"foo\"\"bar\"}"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was expecting COLON at index 6: bar"); | |
} | |
@Test | |
public void test_invalid_object_colon_2() { | |
final String json = "{\"foo\":}"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("premature end of input"); | |
} | |
@Test | |
public void test_invalid_object_token() { | |
final String json = "{\"foo\":\"bar}"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("premature end of input"); | |
} | |
@Test | |
public void test_invalid_object_comma() { | |
final String json = "{\"foo\":\"bar\",}"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was expecting an object key at index 13: OBJECT_END"); | |
} | |
@Test | |
public void test_invalid_object_key() { | |
final String json = "{\"foo\":\"bar\",]}"; | |
Assertions | |
.assertThatThrownBy(() -> JsonReader.read(json)) | |
.as("json=%s", json) | |
.isInstanceOf(IllegalArgumentException.class) | |
.hasMessage("was expecting an object key at index 13: ARRAY_END"); | |
} | |
@Test | |
public void test_nesting() { | |
test( | |
"{\"k1\": [true, null, 1e5, {\"k2\": \"v2\", \"k3\": {\"k4\": \"v4\"}}]}", | |
Collections.singletonMap( | |
"k1", | |
Arrays.asList( | |
true, | |
null, | |
new BigDecimal("1e5"), | |
new LinkedHashMap<String, Object>() {{ | |
put("k2", "v2"); | |
put("k3", Collections.singletonMap("k4", "v4")); | |
}}))); | |
} | |
private void test(final String json, final Object expected) { | |
// Wrapping the assertion one more time to decorate it with the input. | |
Assertions | |
.assertThatCode(() -> Assertions | |
.assertThat(JsonReader.read(json)) | |
.isEqualTo(expected)) | |
.as("json=%s", json) | |
.doesNotThrowAnyException(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment