Skip to content

Instantly share code, notes, and snippets.

@vy
Created March 6, 2020 09:45
Show Gist options
  • Save vy/a8018bbdf5442e998f95008f5959e775 to your computer and use it in GitHub Desktop.
Save vy/a8018bbdf5442e998f95008f5959e775 to your computer and use it in GitHub Desktop.
Single-file simple JSON parser for Java 8
/*
* 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&lt;String,Object&gt;}
* <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);
}
}
}
/*
* 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