Repository: jchambers/pushy
Pull request #780 (2020-05-17 23:48:59)

Drop the generated JSON parser in favor of a hand-made parser
We introduced our own JSON parser in #775. That parser was basically fine, but a little slower than Gson, which is what we were using before and also requires folks to generate the parser from the JavaCC grammar file, which can be confusing.

In this pull request, we jettison the JavaCC-generated parser in favor of an entirely hand-written one. This started off as something of a science experiment to see how fast I could make a really minimal parser and, well:

| Library | Throughput (operations/second) |
|---------|--------------------------------|
| Gson    |            1,280,060 ±  24,619 |
| Jackson |            2,637,754 ±  15,016 |
| Pushy   |            6,137,468 ± 163,153 |

…it turns out I can make it pretty fast, at least for _our specific use case,_ which is mostly parsing error responses from the APNs server. I didn't seriously expect to come out ahead of the JavaCC-generated parser, which was doing something like 800,000 operations/second, and so I'm _really_ surprised that it turned out to be significantly faster than Jackson (again _in this specific use case_).

I think the real win for most folks, though, is just the simplicity of not having to generate sources.


Patch:
diff --git a/pushy/pom.xml b/pushy/pom.xml
index e8d4df34..c19c7b6c 100644
--- a/pushy/pom.xml
+++ b/pushy/pom.xml
@@ -111,26 +111,6 @@
                 </executions>
             </plugin>
 
-            <plugin>
-                <groupId>com.helger.maven</groupId>
-                <artifactId>ph-javacc-maven-plugin</artifactId>
-                <version>4.1.3</version>
-                <executions>
-                    <execution>
-                        <id>generate-parser</id>
-                        <phase>generate-sources</phase>
-                        <goals>
-                            <goal>javacc</goal>
-                        </goals>
-                        <configuration>
-                            <jdkVersion>1.5</jdkVersion>
-                            <sourceDirectory>src/main/javacc</sourceDirectory>
-                            <outputDirectory>${project.build.directory}/generated-sources/javacc</outputDirectory>
-                        </configuration>
-                    </execution>
-                </executions>
-            </plugin>
-
             <plugin>
                 <groupId>org.apache.felix</groupId>
                 <artifactId>maven-bundle-plugin</artifactId>
diff --git a/pushy/src/main/java/com/eatthepath/json/JsonDeserializer.java b/pushy/src/main/java/com/eatthepath/json/JsonDeserializer.java
deleted file mode 100644
index d38407c5..00000000
--- a/pushy/src/main/java/com/eatthepath/json/JsonDeserializer.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.eatthepath.json;
-
-import java.io.StringReader;
-import java.util.Map;
-
-/**
- * <p>An extremely simple JSON object deserializer that interprets JSON objects as Java primitives. JSON types are
- * mapped to Java types as follows:</p>
- *
- * <table>
- *     <caption>Mapping of JSON types to Java types</caption>
- *
- *     <thead>
- *         <tr>
- *             <th>JSON type</th>
- *             <th>Java type</th>
- *         </tr>
- *     </thead>
- *
- *     <tbody>
- *         <tr>
- *             <td>string</td>
- *             <td>{@link String}</td>
- *         </tr>
- *
- *         <tr>
- *             <td>number</td>
- *             <td>{@link Number} ({@link Long} or {@link Double})</td>
- *         </tr>
- *
- *         <tr>
- *             <td>object</td>
- *             <td>{@link Map}&lt;{@link String}, {@link Object}&gt;</td>
- *         </tr>
- *
- *         <tr>
- *             <td>array</td>
- *             <td>{@link java.util.List}&lt;{@link Object}&gt;</td>
- *         </tr>
- *
- *         <tr>
- *             <td>boolean</td>
- *             <td>boolean</td>
- *         </tr>
- *
- *         <tr>
- *             <td>null</td>
- *             <td>null</td>
- *         </tr>
- *     </tbody>
- * </table>
- *
- * <p>{@code JsonDeserializer} instances are <em>not</em> thread-safe; only one thread may safely use a single
- * deserializer at a time.</p>
- *
- * @author <a href="https://github.com/jchambers">Jon Chambers</a>
- *
- * @since 0.14.0
- */
-public class JsonDeserializer {
-
-    private final JsonParser jsonParser = new JsonParser(new StringReader(""));
-
-    /**
-     * Parses a JSON string as a JSON object.
-     *
-     * @param jsonString the JSON string to be parsed
-     *
-     * @return a Java representation of the JSON-encoded object
-     *
-     * @throws ParseException if the given {@code jsonString} does not represent a valid JSON object
-     */
-    public Map<String, Object> parseJsonObject(final String jsonString) throws ParseException {
-        jsonParser.ReInit(new StringReader(jsonString));
-        return jsonParser.object();
-    }
-}
diff --git a/pushy/src/main/java/com/eatthepath/json/JsonParser.java b/pushy/src/main/java/com/eatthepath/json/JsonParser.java
new file mode 100644
index 00000000..892c143e
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/json/JsonParser.java
@@ -0,0 +1,405 @@
+package com.eatthepath.json;
+
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * <p>An extremely simple JSON parser that interprets JSON objects as Java primitives. JSON types are mapped to Java
+ * types as follows:</p>
+ *
+ * <table>
+ *     <caption>Mapping of JSON types to Java types</caption>
+ *
+ *     <thead>
+ *         <tr>
+ *             <th>JSON type</th>
+ *             <th>Java type</th>
+ *         </tr>
+ *     </thead>
+ *
+ *     <tbody>
+ *         <tr>
+ *             <td>string</td>
+ *             <td>{@link String}</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>number</td>
+ *             <td>{@link Number} ({@link Long} or {@link Double})</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>object</td>
+ *             <td>{@link Map}&lt;{@link String}, {@link Object}&gt;</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>array</td>
+ *             <td>{@link List}&lt;{@link Object}&gt;</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>boolean</td>
+ *             <td>boolean</td>
+ *         </tr>
+ *
+ *         <tr>
+ *             <td>null</td>
+ *             <td>null</td>
+ *         </tr>
+ *     </tbody>
+ * </table>
+ *
+ * <p>{@code JsonParser} instances are <em>not</em> thread-safe; only one thread may safely use a single parser at a
+ * time, but parsers can be reused (i.e. callers do not need to construct a new parser for each parsing operation).</p>
+ *
+ * @author <a href="https://github.com/jchambers">Jon Chambers</a>
+ *
+ * @since 0.14.1
+ */
+public class JsonParser {
+
+    private transient String jsonString;
+    private transient int position;
+
+    private transient final StringBuilder stringBuilder = new StringBuilder();
+
+    /**
+     * Constructs a new JSON parser. Parsers are not thread-safe, but may be reused.
+     */
+    public JsonParser() {}
+
+    /**
+     * Parses the given JSON string as a JSON object.
+     *
+     * @param jsonString the JSON string to parse
+     *
+     * @return a Map representing the JSON object
+     *
+     * @throws ParseException if the given JSON string could not be parsed as a JSON object for any reason
+     */
+    public Map<String, Object> parseJsonObject(final String jsonString) throws ParseException {
+        this.jsonString = jsonString;
+        this.position = findNextToken(jsonString, 0);
+
+        if (jsonString.charAt(position) != '{') {
+            throw new ParseException("JSON string does not represent a JSON object.", position);
+        }
+
+        return parseObject();
+    }
+
+    /**
+     * Parses and returns the JSON value (which may be of any JSON type) at the current read position and advances the
+     * read position to the character after the end of the value.
+     *
+     * @return the value at the current read position
+     */
+    Object parseNextValue() throws ParseException {
+        position = findNextToken(jsonString, position);
+
+        final char c = jsonString.charAt(position);
+        final Object value;
+
+        if (c == '"') {
+            value = parseString();
+        } else if (c == '-' || (c >= '0' && c <= '9')) {
+            final int numberStart = position;
+
+            try {
+                value = parseNumber();
+            } catch (final NumberFormatException e) {
+                throw new ParseException("Could not parse number", numberStart);
+            }
+        } else if (c == '{') {
+            value = parseObject();
+        } else if (c == '[') {
+            value = parseList();
+        } else if (c == 't') {
+            if (jsonString.regionMatches(position, "true", 0, 4)) {
+                value = true;
+                position += 4;
+            } else {
+                throw new ParseException("Unexpected literal", position);
+            }
+        } else if (c == 'f') {
+            if (jsonString.regionMatches(position, "false", 0, 5)) {
+                value = false;
+                position += 5;
+            } else {
+                throw new ParseException("Unexpected literal", position);
+            }
+        } else if (c == 'n') {
+            if (jsonString.regionMatches(position, "null", 0, 4)) {
+                value = null;
+                position += 4;
+            } else {
+                throw new ParseException("Unexpected literal", position);
+            }
+        } else {
+            throw new ParseException("Unexpected token", position);
+        }
+
+        return value;
+    }
+
+    /**
+     * Parses and returns the string at the current read position and advances the read position to the character after
+     * the end of the string.
+     *
+     * @return the string at the current read position
+     */
+    String parseString() throws ParseException {
+        // Reset and reuse the string builder to avoid unnecessary allocations
+        stringBuilder.setLength(0);
+
+        // Skip the opening quote
+        position += 1;
+        
+        int start = position;
+
+        for (; position < jsonString.length(); position++) {
+            final char c = jsonString.charAt(position);
+
+            if (c == '\\') {
+                final int charsConsumed;
+                final char escapedChar;
+
+                if (position + 1 >= jsonString.length()) {
+                    throw new ParseException("Expected escaped character, but reached end of input", position);
+                }
+
+                switch (jsonString.charAt(position + 1)) {
+                    case '\"': {
+                        charsConsumed = 1;
+                        escapedChar = '\"';
+                        break;
+                    }
+
+                    case '\\': {
+                        charsConsumed = 1;
+                        escapedChar = '\\';
+                        break;
+                    }
+
+                    case '/': {
+                        charsConsumed = 1;
+                        escapedChar = '/';
+                        break;
+                    }
+
+                    case 'b': {
+                        charsConsumed = 1;
+                        escapedChar = '\b';
+                        break;
+                    }
+
+                    case 'f': {
+                        charsConsumed = 1;
+                        escapedChar = '\f';
+                        break;
+                    }
+
+                    case 'n': {
+                        charsConsumed = 1;
+                        escapedChar = '\n';
+                        break;
+                    }
+
+                    case 'r': {
+                        charsConsumed = 1;
+                        escapedChar = '\r';
+                        break;
+                    }
+
+                    case 't': {
+                        charsConsumed = 1;
+                        escapedChar = '\t';
+                        break;
+                    }
+
+                    case 'u': {
+                        if (position + 5 >= jsonString.length()) {
+                            throw new ParseException("Expected escaped unicode sequence, but reached end of input", position);
+                        }
+
+                        charsConsumed = 5;
+                        escapedChar = (char) Integer.parseInt(jsonString.substring(position + 2, position + 6), 16);
+                        break;
+                    }
+
+                    default: {
+                        throw new ParseException("Illegal escaped character: " + jsonString.charAt(position + 1), position);
+                    }
+                }
+
+                stringBuilder.append(jsonString, start, position);
+                stringBuilder.append(escapedChar);
+
+                position += charsConsumed;
+                start = position + 1;
+            } else if (c == '"') {
+                // If the string builder is empty, we didn't wind up un-escaping anything and can just return a
+                // substring to save an array copy (original string -> returned string as opposed to original string ->
+                // string builder -> returned string).
+                final String unescapedString;
+
+                if (stringBuilder.length() == 0) {
+                    unescapedString = jsonString.substring(start, position);
+                } else {
+                    stringBuilder.append(jsonString, start, jsonString.length() - 1);
+                    unescapedString = stringBuilder.toString();
+                }
+
+                // Position the read index after the end of the string
+                position += 1;
+
+                return unescapedString;
+            }
+        }
+
+        throw new ParseException("Expected \", but reached end of input.", position);
+    }
+
+    /**
+     * Parses and returns the number at the current read position and advances the read position to the character after
+     * the end of the number token.
+     *
+     * @return the number at the current read position
+     */
+    Number parseNumber() {
+        final boolean negate = jsonString.charAt(position) == '-';
+        boolean parseAsDouble = false;
+
+        final int startInclusive = position;
+        int endExclusive;
+
+        long parsedLong = 0;
+
+        for (endExclusive = negate ? position + 1 : position; endExclusive < jsonString.length(); endExclusive++) {
+            final char c = jsonString.charAt(endExclusive);
+
+            if (c >= '0' && c <= '9') {
+                parsedLong *= 10;
+                parsedLong += c - '0';
+            } else if (c == '.' || c == 'e' || c == 'E' || c == '+' || (c == '-')) {
+                parseAsDouble = true;
+            } else {
+                break;
+            }
+        }
+
+        position = endExclusive;
+
+        if (parseAsDouble) {
+            return Double.parseDouble(jsonString.substring(startInclusive, endExclusive));
+        } else {
+            return negate ? -parsedLong : parsedLong;
+        }
+    }
+
+    /**
+     * Parses and returns the object at the current read position and advances the read position to the character after
+     * the end of the object.
+     *
+     * @return the object at the current read position
+     */
+    Map<String, Object> parseObject() throws ParseException {
+        // Advance past the opening bracket
+        position = findNextToken(jsonString, position + 1);
+
+        final Map<String, Object> map = new HashMap<>();
+
+        while (jsonString.charAt(position) != '}') {
+            if (jsonString.charAt(position) != '"') {
+                throw new ParseException("Expected a string", position);
+            }
+
+            final String key = parseString();
+
+            position = findNextToken(jsonString, position);
+
+            if (jsonString.charAt(position) != ':') {
+                throw new ParseException("Expected ':'", position);
+            }
+
+            // Advance past the key/value separator
+            position += 1;
+
+            map.put(key, parseNextValue());
+
+            position = findNextToken(jsonString, position);
+
+            if (jsonString.charAt(position) == ',') {
+                // Position the cursor at the start of the token after the comma
+                position = findNextToken(jsonString, position + 1);
+            }
+        }
+
+        // Advance past the closing bracket
+        position += 1;
+
+        return map;
+    }
+
+    /**
+     * Parses and returns the list at the current read position and advances the read position to the character after
+     * the end of the list.
+     *
+     * @return the list at the current read position
+     */
+    List<Object> parseList() throws ParseException {
+        // Advance past the opening bracket
+        position = findNextToken(jsonString, position + 1);
+
+        final List<Object> list = new ArrayList<>();
+
+        while (jsonString.charAt(position) != ']') {
+            list.add(parseNextValue());
+
+            position = findNextToken(jsonString, position);
+
+            if (jsonString.charAt(position) == ',') {
+                // Position the cursor at the start of the token after the comma
+                position = findNextToken(jsonString, position + 1);
+            }
+        }
+
+        // Advance past the closing bracket
+        position += 1;
+
+        return list;
+    }
+
+    // Visible for testing
+    void setJsonString(final String jsonString) {
+        this.jsonString = jsonString;
+        this.position = 0;
+    }
+
+    /**
+     * Returns the index of the first character in the given string at or after the given start index that may be the
+     * first character of a non-whitespace JSON token.
+     *
+     * @param string the string in which to find a token
+     * @param start the first index at which to look for a non-whitespace token
+     *
+     * @return the index of the first non-whitespace character in the given string at or after the given start index
+     *
+     * @throws ParseException if the end of the string was reached before a non-whitespace token was found
+     */
+    static int findNextToken(final String string, final int start) throws ParseException {
+        for (int i = start; i < string.length(); i++) {
+            final char c = string.charAt(i);
+
+            if (!(c == ' ' || c == '\t' || c == '\r' || c == '\n')) {
+                return i;
+            }
+        }
+
+        throw new ParseException("Expected token, but reached end of input", start);
+    }
+}
diff --git a/pushy/src/main/java/com/eatthepath/json/package-info.java b/pushy/src/main/java/com/eatthepath/json/package-info.java
new file mode 100644
index 00000000..a5cd9804
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/json/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2020 Jon Chambers
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Contains classes for parsing and serializing JSON.
+ *
+ * @author <a href="https://github.com/jchambers">Jon Chambers</a>
+ */
+package com.eatthepath.json;
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
index 726877ab..3767997d 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
@@ -22,8 +22,7 @@
 
 package com.eatthepath.pushy.apns;
 
-import com.eatthepath.json.JsonDeserializer;
-import com.eatthepath.json.ParseException;
+import com.eatthepath.json.JsonParser;
 import com.eatthepath.pushy.apns.util.concurrent.PushNotificationFuture;
 import com.eatthepath.uuid.FastUUID;
 import io.netty.buffer.ByteBuf;
@@ -48,6 +47,7 @@ import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
 import java.time.Duration;
 import java.util.Map;
 import java.util.Objects;
@@ -84,7 +84,7 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
     private static final IOException STREAM_CLOSED_BEFORE_REPLY_EXCEPTION =
             new IOException("Stream closed before a reply was received");
 
-    private final JsonDeserializer jsonDeserializer = new JsonDeserializer();
+    private final JsonParser jsonParser = new JsonParser();
 
     private static final Logger log = LoggerFactory.getLogger(ApnsClientHandler.class);
 
@@ -332,7 +332,7 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
 
                 try {
                     errorResponse = ErrorResponse.fromMap(
-                            jsonDeserializer.parseJsonObject(data.toString(StandardCharsets.UTF_8)));
+                            jsonParser.parseJsonObject(data.toString(StandardCharsets.UTF_8)));
                 } catch (final ParseException e) {
                     log.error("Failed to parse error response: {}", data.toString(StandardCharsets.UTF_8));
                     errorResponse = new ErrorResponse(null, null);
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java b/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
index 16a31ba5..fc2ceb15 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
@@ -22,9 +22,8 @@
 
 package com.eatthepath.pushy.apns.auth;
 
-import com.eatthepath.json.JsonDeserializer;
+import com.eatthepath.json.JsonParser;
 import com.eatthepath.json.JsonSerializer;
-import com.eatthepath.json.ParseException;
 import io.netty.buffer.ByteBuf;
 import io.netty.buffer.Unpooled;
 import io.netty.handler.codec.base64.Base64;
@@ -36,6 +35,7 @@ import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.security.Signature;
 import java.security.SignatureException;
+import java.text.ParseException;
 import java.time.Instant;
 import java.util.HashMap;
 import java.util.Map;
@@ -201,11 +201,11 @@ public class AuthenticationToken {
             throw new IllegalArgumentException();
         }
 
-        final JsonDeserializer jsonDeserializer = new JsonDeserializer();
+        final JsonParser jsonParser = new JsonParser();
 
         try {
             this.header = AuthenticationTokenHeader.fromMap(
-                    jsonDeserializer.parseJsonObject(
+                    jsonParser.parseJsonObject(
                             new String(decodeBase64UrlEncodedString(jwtSegments[0]), StandardCharsets.US_ASCII)));
         } catch (final ParseException e) {
             throw new IllegalArgumentException("Could not parse header as a JSON object: " +
@@ -214,7 +214,7 @@ public class AuthenticationToken {
 
         try {
             this.claims = AuthenticationTokenClaims.fromMap(
-                    jsonDeserializer.parseJsonObject(
+                    jsonParser.parseJsonObject(
                             new String(decodeBase64UrlEncodedString(jwtSegments[1]), StandardCharsets.US_ASCII)));
         } catch (final ParseException e) {
             throw new IllegalArgumentException("Could not parse claims as a JSON object: " +
diff --git a/pushy/src/main/javacc/JsonParser.jj b/pushy/src/main/javacc/JsonParser.jj
deleted file mode 100644
index 8d30f228..00000000
--- a/pushy/src/main/javacc/JsonParser.jj
+++ /dev/null
@@ -1,138 +0,0 @@
-options {
-    CHOICE_AMBIGUITY_CHECK = 1;
-    SUPPORT_CLASS_VISIBILITY_PUBLIC = false;
-}
-
-PARSER_BEGIN(JsonParser)
-
-package com.eatthepath.json;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-class JsonParser {
-}
-
-PARSER_END(JsonParser)
-
-TOKEN_MGR_DECLS :
-{
-  private static final Map<String, Character> ESCAPED_CHARACTERS = new HashMap<>();
-
-  static {
-    ESCAPED_CHARACTERS.put("\\\"", '\"');
-    ESCAPED_CHARACTERS.put("\\\\", '\\');
-    ESCAPED_CHARACTERS.put("\\/", '/');
-    ESCAPED_CHARACTERS.put("\\b", '\b');
-    ESCAPED_CHARACTERS.put("\\f", '\f');
-    ESCAPED_CHARACTERS.put("\\n", '\n');
-    ESCAPED_CHARACTERS.put("\\r", '\r');
-    ESCAPED_CHARACTERS.put("\\t", '\t');
-  }
-}
-
-SKIP : {
-    " " | "\t" | "\r" | "\n"
-}
-
-SKIP : {
-    "\"" : WithinString
-}
-
-<WithinString> MORE : {
-    <ESCAPED_CONTROL_CHARACTER: "\\" ["\"", "\\", "/", "b", "f", "n", "r", "t"]> {
-        final char escapedCharacter = ESCAPED_CHARACTERS.get(image.substring(image.length() - lengthOfMatch));
-
-        image.setLength(image.length() - lengthOfMatch);
-        image.append(escapedCharacter);
-    } |
-
-    <#HEX_DIGIT: ["0"-"9", "a"-"f", "A"-"F"]> |
-    <ESCAPED_UNICODE_CHARACTER: "\\u" <HEX_DIGIT> <HEX_DIGIT> <HEX_DIGIT> <HEX_DIGIT>> {
-        final char unicodeCharacter = (char) Integer.parseInt(image.substring(image.length() - 4), 16);
-
-        image.setLength(image.length() - lengthOfMatch);
-        image.append(unicodeCharacter);
-    } |
-
-    <NON_CONTROL_CHARACTER: ~["\"", "\\", "\b", "\f", "\n", "\r", "\t"]>
-}
-
-<WithinString> TOKEN : {
-    <STRING: "\""> {
-        matchedToken.image = image.substring(0, image.length() - 1);
-    } : DEFAULT
-}
-
-TOKEN : {
-    <#DECIMAL_DIGIT: ["0"-"9"]> |
-    <INTEGER: ("-")? (<DECIMAL_DIGIT>)+> |
-    <FLOAT: ("-")? (<DECIMAL_DIGIT>)+ ("." (<DECIMAL_DIGIT>)+)? (["e", "E"] (["+", "-"])? (<DECIMAL_DIGIT>)+)?> |
-
-    <TRUE: "true"> |
-    <FALSE: "false"> |
-
-    <NULL: "null">
-}
-
-Map<String, Object> object():
-{
-    final Map<String, Object> map = new HashMap<>();
-
-    String key;
-    Object value;
-}
-{
-    "{" (key = string() ":" value = value() { map.put(key, value); }
-        ("," key = string() ":" value = value() { map.put(key, value); })*
-    )? "}" { return map; }
-}
-
-List<Object> list():
-{
-    final List<Object> list = new ArrayList<>();
-    Object element;
-}
-{
-    "[" (element = value() { list.add(element); }
-        ("," element = value() { list.add(element); })*
-    )? "]" { return list; }
-}
-
-Object value():
-{
-    final Object value;
-}
-{
-    (
-        value = string() |
-        value = number() |
-        value = object() |
-        value = list() |
-        <TRUE> { value = true; } |
-        <FALSE> { value = false; } |
-        <NULL> { value = null; }
-    ) { return value; }
-}
-
-String string():
-{
-    final Token token;
-}
-{
-    token = <STRING> { return token.image; }
-}
-
-Number number():
-{
-    final Number number;
-    final Token token;
-}
-{
-    (
-        token = <INTEGER> { number = Long.parseLong(token.image, 10); } |
-        token = <FLOAT> { number = Double.parseDouble(token.image); }
-    ) { return number; }
-}


Test patch:
diff --git a/pushy/src/test/java/com/eatthepath/json/JsonDeserializerTest.java b/pushy/src/test/java/com/eatthepath/json/JsonDeserializerTest.java
deleted file mode 100644
index 3241bdd6..00000000
--- a/pushy/src/test/java/com/eatthepath/json/JsonDeserializerTest.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.eatthepath.json;
-
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
-import org.junit.jupiter.params.provider.ValueSource;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.stream.Stream;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.params.provider.Arguments.arguments;
-
-class JsonDeserializerTest {
-
-    @ParameterizedTest
-    @MethodSource("argumentsForParseJsonObject")
-    void parseJsonObject(final String jsonString, final Map<String, Object> expectedObject) throws ParseException {
-        assertEquals(expectedObject, new JsonDeserializer().parseJsonObject(jsonString));
-    }
-
-    private static Stream<Arguments> argumentsForParseJsonObject() {
-        final Map<String, Object> multipleEntryMap = new HashMap<>();
-        multipleEntryMap.put("moose", 12L);
-        multipleEntryMap.put("porcupine", true);
-
-        return Stream.of(
-                arguments("{}", Collections.emptyMap()),
-                arguments("{\"moose\": 12}", Collections.singletonMap("moose", 12L)),
-                arguments("{\"moose\": 12, \"porcupine\": true}", multipleEntryMap),
-                arguments("{\"moose\": {\"porcupine\": true}}",
-                        Collections.singletonMap("moose", Collections.singletonMap("porcupine", true))));
-    }
-
-    @ParameterizedTest
-    @ValueSource(strings = {"", "{", "false", "{{}"})
-    void parseJsonObjectBogusJsonString(final String jsonString) {
-        assertThrows(ParseException.class, () -> new JsonDeserializer().parseJsonObject(jsonString));
-    }
-
-    @Test
-    void parseJsonObjectReuse() throws ParseException {
-        final JsonDeserializer jsonDeserializer = new JsonDeserializer();
-
-        assertEquals(jsonDeserializer.parseJsonObject("{\"moose\": 12}"),
-                Collections.singletonMap("moose", 12L));
-
-        assertEquals(jsonDeserializer.parseJsonObject("{\"moose\": {\"porcupine\": true}}"),
-                Collections.singletonMap("moose", Collections.singletonMap("porcupine", true)));
-    }
-}
diff --git a/pushy/src/test/java/com/eatthepath/json/JsonParserTest.java b/pushy/src/test/java/com/eatthepath/json/JsonParserTest.java
index 22d9b796..4667c3fd 100644
--- a/pushy/src/test/java/com/eatthepath/json/JsonParserTest.java
+++ b/pushy/src/test/java/com/eatthepath/json/JsonParserTest.java
@@ -1,97 +1,197 @@
 package com.eatthepath.json;
 
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
-import java.io.StringReader;
+import java.text.ParseException;
 import java.util.*;
 import java.util.stream.Stream;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.*;
 import static org.junit.jupiter.params.provider.Arguments.arguments;
 
 class JsonParserTest {
 
+    @Test
+    void parseAsObject() throws ParseException {
+        final JsonParser parser = new JsonParser();
+
+        assertThrows(ParseException.class, () -> parser.parseJsonObject(""));
+        assertThrows(ParseException.class, () -> parser.parseJsonObject("[]"));
+
+        assertEquals(Collections.singletonMap("moose", true),
+                parser.parseJsonObject("  \t\r\n{   \"moose\"\t: true    }\n\n"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("argumentsForParseNextValue")
+    void parseNextValue(final String json, final Object expectedValue) throws ParseException {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
+
+        assertEquals(expectedValue, parser.parseNextValue());
+    }
+
+    private static Stream<Arguments> argumentsForParseNextValue() {
+        return Stream.of(
+                arguments("true", true),
+                arguments("false", false),
+                arguments("null", null),
+                arguments(" true", true),
+                arguments("\tfalse", false),
+                arguments("\r\nnull", null)
+        );
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"not value json", "()", "+", "fun"})
+    void parseNextValueBadToken(final String json) {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
+
+        assertThrows(ParseException.class, parser::parseNextValue);
+    }
+
+    @ParameterizedTest
+    @MethodSource("argumentsForParseString")
+    void parseString(final String json, final String expectedString) throws ParseException {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
+
+        assertEquals(expectedString, parser.parseString());
+    }
+
+    private static Stream<Arguments> argumentsForParseString() {
+        return Stream.of(
+                arguments("\"\"", ""),
+                arguments("\"test\"", "test"),
+                arguments("\"\\t\"", "\t"),
+                arguments("\"Line\\nbreak\"", "Line\nbreak"),
+                arguments("\"A string with \\\"quotes.\\\"\"", "A string with \"quotes.\""),
+                arguments("\"\\u2713\"", "✓"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "\"", "\"Oh no \\", "\"Bad unicode \\u123" })
+    void parseStringBadValue(final String json) {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
+
+        assertThrows(ParseException.class, parser::parseString);
+    }
+
+    @ParameterizedTest
+    @MethodSource("argumentsForParseNumber")
+    void parseNumber(final String json, final Number expectedNumber) {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
+
+        final Number number = parser.parseNumber();
+
+        assertEquals(expectedNumber, number);
+    }
+
+    private static Stream<Arguments> argumentsForParseNumber() {
+        return Stream.of(
+                arguments("0", 0L),
+                arguments("-1", -1L),
+                arguments("1234567890", 1234567890L),
+                arguments("1.5", 1.5),
+                arguments("-1.5", -1.5),
+                arguments("2e3", 2e3),
+                arguments("-2e-3", -2e-3));
+    }
+
     @ParameterizedTest
-    @MethodSource("argumentsForObject")
-    void object(final String jsonString, final Map<String, Object> expectedMap) throws ParseException {
-        assertEquals(expectedMap, new JsonParser(new StringReader(jsonString)).object());
+    @MethodSource("argumentsForParseObject")
+    void parseObject(final String json, final Map<String, Object> expectedMap) throws ParseException {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
+
+        assertEquals(expectedMap, parser.parseObject());
     }
 
-    private static Stream<Arguments> argumentsForObject() {
+    private static Stream<Arguments> argumentsForParseObject() {
         final Map<String, Object> multipleEntryMap = new HashMap<>();
         multipleEntryMap.put("moose", 12L);
         multipleEntryMap.put("porcupine", true);
 
+        final Map<String, Object> nestedMap = new HashMap<>();
+        nestedMap.put("a", Collections.emptyMap());
+        nestedMap.put("b", Collections.emptyList());
+        nestedMap.put("c", false);
+
         return Stream.of(
                 arguments("{}", Collections.emptyMap()),
                 arguments("{\"moose\": 12}", Collections.singletonMap("moose", 12L)),
                 arguments("{\"moose\": 12, \"porcupine\": true}", multipleEntryMap),
                 arguments("{\"moose\": {\"porcupine\": true}}",
-                        Collections.singletonMap("moose", Collections.singletonMap("porcupine", true))));
+                        Collections.singletonMap("moose", Collections.singletonMap("porcupine", true))),
+                arguments("{\"a\": {}, \"b\": [], \"c\": false}", nestedMap));
     }
 
     @ParameterizedTest
-    @MethodSource("argumentsForList")
-    void list(final String jsonString, final List<Object> expectedList) throws ParseException {
-        assertEquals(expectedList, new JsonParser(new StringReader(jsonString)).list());
+    @ValueSource(strings = {"{", "{\"key\"", "{\"key\":", "{\"key\": 12", "{false: true}"})
+    void parseObjectBadValue(final String json) {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
+
+        assertThrows(ParseException.class, parser::parseObject);
     }
 
-    private static Stream<Arguments> argumentsForList() {
+    @ParameterizedTest
+    @MethodSource("argumentsForParseList")
+    void parseList(final String json, final List<Object> expectedList) throws ParseException {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
+
+        assertEquals(expectedList, parser.parseList());
+    }
+
+    private static Stream<Arguments> argumentsForParseList() {
         return Stream.of(
                 arguments("[]", Collections.emptyList()),
+                arguments("[ ]", Collections.emptyList()),
                 arguments("[true]", Collections.singletonList(true)),
-                arguments("[true, \"electrode\"]", Arrays.asList(true, "electrode")));
+                arguments("[true, false, true]", Arrays.asList(true, false, true)),
+                arguments("[[], {}, false]", Arrays.asList(Collections.emptyList(), Collections.emptyMap(), false)));
     }
 
     @ParameterizedTest
-    @MethodSource("argumentsForValue")
-    void value(final String jsonString, final Object expectedValue) throws ParseException {
-        assertEquals(expectedValue, new JsonParser(new StringReader(jsonString)).value());
-    }
+    @ValueSource(strings = {"[", "[,", "[12", "[12,"})
+    void parseListBadValue(final String json) {
+        final JsonParser parser = new JsonParser();
+        parser.setJsonString(json);
 
-    private static Stream<Arguments> argumentsForValue() {
-        return Stream.of(
-                arguments("\"test\"", "test"),
-                arguments("17", 17L),
-                arguments("{\"test\": \"moose\"}", Collections.singletonMap("test", "moose")),
-                arguments("[7, false, null]", Arrays.asList(7L, false, null)),
-                arguments("true", true),
-                arguments("false", false),
-                arguments("null", null));
+        assertThrows(ParseException.class, parser::parseList);
     }
 
     @ParameterizedTest
-    @MethodSource("argumentsForString")
-    void string(final String jsonString, final String expectedString) throws ParseException {
-        assertEquals(expectedString, new JsonParser(new StringReader(jsonString)).string());
+    @MethodSource("argumentsForFindNextToken")
+    void findNextToken(final String string, final int start, final int expectedTokenIndex) throws ParseException {
+        assertEquals(expectedTokenIndex, JsonParser.findNextToken(string, start));
     }
 
-    private static Stream<Arguments> argumentsForString() {
+    private static Stream<Arguments> argumentsForFindNextToken() {
         return Stream.of(
-                arguments("\"\"", ""),
-                arguments("\"test\"", "test"),
-                arguments("\"\\t\"", "\t"),
-                arguments("\"Line\\nbreak\"", "Line\nbreak"),
-                arguments("\"\\u2713\"", "✓"));
+                arguments("test", 0, 0),
+                arguments(" {}", 0, 1),
+                arguments(" {}", 1, 1));
     }
 
     @ParameterizedTest
-    @MethodSource("argumentsForNumber")
-    void number(final String jsonString, final Number expectedNumber) throws ParseException {
-        assertEquals(expectedNumber, new JsonParser(new StringReader(jsonString)).number());
+    @MethodSource("argumentsForFindNextTokenEndOfString")
+    void findNextTokenEndOfString(final String string, final int start) {
+        assertThrows(ParseException.class, () -> JsonParser.findNextToken(string, start));
     }
 
-    private static Stream<Arguments> argumentsForNumber() {
+    private static Stream<Arguments> argumentsForFindNextTokenEndOfString() {
         return Stream.of(
-                arguments("0", 0L),
-                arguments("1", 1L),
-                arguments("1234567890", 1234567890L),
-                arguments("-1234567890", -1234567890L),
-                arguments("1.23", 1.23),
-                arguments("-1.23", -1.23),
-                arguments("-2e4", -2e4),
-                arguments("7.2e-3", 7.2e-3));
+                arguments("", 0),
+                arguments("", 1),
+                arguments("test", 5));
     }
 }
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
index dc7fb154..69a2b511 100644
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
@@ -22,14 +22,14 @@
 
 package com.eatthepath.pushy.apns.util;
 
-import com.eatthepath.json.JsonDeserializer;
-import com.eatthepath.json.ParseException;
+import com.eatthepath.json.JsonParser;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
+import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -506,7 +506,7 @@ public abstract class ApnsPayloadBuilderTest {
 
         this.builder.addCustomProperty(customKey, customValue);
 
-        final Map<String, Object> payload = new JsonDeserializer().parseJsonObject(this.builder.build());
+        final Map<String, Object> payload = new JsonParser().parseJsonObject(this.builder.build());
 
         assertEquals(customValue, payload.get(customKey));
     }
@@ -588,9 +588,9 @@ public abstract class ApnsPayloadBuilderTest {
         final Map<String, Object> payload;
 
         try {
-            payload = new JsonDeserializer().parseJsonObject(payloadString);
+            payload = new JsonParser().parseJsonObject(payloadString);
         } catch (final ParseException e) {
-            throw new IllegalArgumentException("Failed to parse JSON", e);
+            throw new IllegalArgumentException("Failed to parse JSON: " + payloadString, e);
         }
 
         return (Map<String, Object>) payload.get("aps");