Skip to content

Instantly share code, notes, and snippets.

@mukel
Last active November 22, 2024 20:27
Show Gist options
  • Save mukel/7e3b65652fb0e7554f986fe697bb2f33 to your computer and use it in GitHub Desktop.
Save mukel/7e3b65652fb0e7554f986fe697bb2f33 to your computer and use it in GitHub Desktop.
A lightweight JSON parser and printer utility class.
import java.util.*;
/**
* A lightweight JSON parser and printer utility class.
* This class provides functionality to parse JSON strings into Java objects and
* convert Java objects back to JSON strings.
*
* <p>The parser supports all standard JSON data types:
* <ul>
* <li>Objects ({@code Map<String, Object>})</li>
* <li>Arrays ({@code List<Object>})</li>
* <li>Strings</li>
* <li>Numbers (both integer and floating-point)</li>
* <li>Booleans</li>
* <li>null</li>
* </ul>
*
* <p>Usage example:
* <pre>{@code
* // Parsing JSON
* String jsonStr = "{\"name\":\"John\",\"age\":30}";
* Object parsed = JSON.parse(jsonStr);
*
* // Converting to JSON
* var map = Map.of("key", List.of(JSON.NULL, 123, "foo", Math.PI)
* String json = JSON.print(map, true); // Pretty-printed
* }</pre>
*/
final class JSON {
/**
* Represents a JSON null value. Used to distinguish between Java null and JSON null.
*/
public static final Object NULL = new Object() {
@Override
public String toString() {
return "JSON.NULL";
}
};
/**
* Parses a JSON string into corresponding Java objects.
*
* @param chars The JSON string to parse
* @return The parsed object, which can be one of:
* {@code Map<String, Object>} for JSON objects,
* {@code List<Object>} for JSON arrays,
* {@code String} for JSON strings,
* {@code Number} for JSON numbers,
* {@code Boolean} for JSON booleans,
* or {@code JSON.NULL} for JSON null
* @throws ParseException if the input is not valid JSON
*/
public static Object parse(CharSequence chars) {
JSON json = new JSON(chars);
Object result = json.parse();
json.consumeEndOfInput(true);
return result;
}
/**
* Converts a Java object into a JSON string.
*
* @param json The object to convert to JSON
* @param pretty Whether to format the output with indentation and line breaks
* @return A JSON string representation of the input object
* @throws IllegalArgumentException if the input contains objects that cannot be converted to JSON
*/
public static String print(Object json, boolean pretty) {
StringBuilder sb = new StringBuilder();
printImpl(sb, json, pretty, 0);
return sb.toString();
}
// Private implementation details
private final CharSequence chars;
private int index;
private JSON(CharSequence chars) {
this.chars = chars;
this.index = 0;
}
/**
* Main parsing method that handles all JSON value types.
*/
private Object parse() {
char ch = currentChar(true);
return switch (ch) {
case 't' -> {
consume("true");
yield true;
}
case 'f' -> {
consume("false");
yield false;
}
case 'n' -> {
consume("null");
yield JSON.NULL;
}
case '"' -> parseString();
case '[' -> parseList();
case '{' -> parseMap();
case '-', '+', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> parseNumber();
default -> throw parseException("Unexpected character " + HexFormat.of().toHexDigits(ch, 4));
};
}
/**
* Parses a JSON array into a List.
*/
private List<Object> parseList() {
consume('[', true);
List<Object> list = new ArrayList<>();
while (currentChar(true) != ']') {
list.add(parse());
if (currentChar(true) == ',') {
++index;
} else {
break;
}
}
consume(']', true);
return list;
}
private Map<String, Object> parseMap() {
consume('{', true);
Map<String, Object> map = new LinkedHashMap<>();
while (currentChar(true) != '}') {
String key = parseString();
consume(':', true);
Object value = parse();
map.put(key, value);
if (currentChar(true) == ',') {
++index;
} else {
break;
}
}
consume('}', true);
return map;
}
private String parseString() {
consume('"', true);
StringBuilder sb = new StringBuilder();
char ch;
while ((ch = currentChar()) != '"') {
if (ch == '\\') {
index++;
char escapeSuffix = currentChar();
index++;
switch (escapeSuffix) {
case '"' -> sb.append('"');
case '\\' -> sb.append('\\');
case '/' -> sb.append('/');
case 'b' -> sb.append('\b');
case 'f' -> sb.append('\f');
case 'n' -> sb.append('\n');
case 'r' -> sb.append('\r');
case 't' -> sb.append('\t');
case 'u' -> {
int result = 0;
for (int i = 0; i < 4; ++i) {
result = (result << 4) | Character.digit(parseHexDigit(), 16);
}
sb.append((char) result);
}
default -> throw parseException("Unsupported escape sequence '\\" + escapeSuffix + "'");
}
} else {
boolean isUnescaped = isUnescaped(ch);
if (isUnescaped) {
sb.append(ch);
index++;
} else {
throw parseException("Expected unescaped character but found '" + ch + "'");
}
}
}
consume('"');
return sb.toString();
}
private static boolean isUnescaped(char ch) {
return within(ch, (char) 0x20, (char) 0x21)
|| within(ch, (char) 0x23, (char) 0x5B)
|| within(ch, (char) 0x5D, (char) 0xFFFF);
}
private Number parseNumber() {
skipWhitespaces();
int startIndex = index;
while (index < chars.length() && "-+.eE0123456789".indexOf(currentChar()) >= 0) {
++index;
}
String numberStr = chars.subSequence(startIndex, index).toString();
boolean isIntegral = numberStr.indexOf('.') < 0 && numberStr.indexOf('e') < 0 && numberStr.indexOf('E') < 0;
if (isIntegral) {
try {
return Long.parseLong(numberStr);
} catch (NumberFormatException e) {
// TODO: Try BigInteger instead.
throw parseException("Cannot parse number", e);
}
} else {
try {
return Double.parseDouble(numberStr);
} catch (NumberFormatException e) {
// TODO: Try BigDecimal instead.
throw parseException("Cannot parse number", e);
}
}
}
private char parseHexDigit() {
char ch = currentChar();
if (isDigit(ch) || within(ch, 'a', 'f') || within(ch, 'A', 'F')) {
index++;
return ch;
} else {
throw parseException("Expected hex digit 0-9a-fA-F but found '" + ch + "'");
}
}
private ParseException parseException(String message, Throwable cause) {
throw new ParseException(index + ": " + message, cause);
}
private ParseException parseException(String message) {
throw new ParseException(index + ": " + message);
}
private void skipWhitespaces() {
while (index < chars.length() && isWhitespace(chars.charAt(index))) {
++index;
}
}
private static boolean isWhitespace(char ch) {
return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r';
}
private void consume(char ch, boolean skipWhitespaces) {
if (skipWhitespaces) {
skipWhitespaces();
}
consume(ch);
}
private void consume(char ch) {
if (currentChar() == ch) {
++index;
} else {
throw parseException("unexpected char");
}
}
private void consume(String expected) {
if (index > chars.length() - expected.length()) {
throw parseException("expected \'" + expected + "\" but end-of-input found");
}
for (int i = 0; i < expected.length(); ++i) {
if (expected.charAt(i) != chars.charAt(index)) {
throw parseException("expected \'" + expected + "\"");
}
++index;
}
}
private void consumeEndOfInput(boolean skipWhitespaces) {
if (skipWhitespaces) {
skipWhitespaces();
}
if (index < chars.length()) {
throw parseException("Expected end-of-input");
}
}
private static boolean isDigit(char ch) {
return within(ch, '0', '9');
}
private static boolean within(char ch, char lo, char hi) {
return lo <= ch && ch <= hi;
}
private char currentChar() {
if (index < chars.length()) {
return chars.charAt(index);
} else {
throw parseException("end-of-input");
}
}
private char currentChar(boolean skipWhitespaces) {
if (skipWhitespaces) {
skipWhitespaces();
}
return currentChar();
}
private static void printImpl(StringBuilder sb, Object json, boolean pretty, int indentLevel) {
switch (json) {
case Boolean bool -> sb.append(bool);
case Number number -> sb.append(number);
case CharSequence string -> printString(sb, string);
case List<?> list -> {
sb.append('[');
boolean first = true;
for (Object elem : list) {
if (first) {
first = false;
} else {
sb.append(',');
}
if (pretty) {
sb.append(System.lineSeparator());
sb.append(" ".repeat(indentLevel + 1));
}
printImpl(sb, elem, pretty, indentLevel + 1);
}
if (pretty && !first) {
sb.append(System.lineSeparator());
sb.append(" ".repeat(indentLevel));
}
sb.append(']');
}
case Map<?, ?> map -> {
sb.append('{');
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (first) {
first = false;
} else {
sb.append(',');
}
if (pretty) {
sb.append(System.lineSeparator());
sb.append(" ".repeat(indentLevel + 1));
}
printImpl(sb, (CharSequence) entry.getKey(), pretty, indentLevel);
sb.append(pretty ? " : " : ":");
printImpl(sb, entry.getValue(), pretty, indentLevel + 1);
}
if (!first) {
sb.append(System.lineSeparator());
sb.append(" ".repeat(indentLevel));
}
sb.append('}');
}
default -> {
if (json == NULL) {
sb.append("null");
} else {
throw new IllegalArgumentException("unexpected element of type " + json.getClass());
}
}
}
}
/**
* string = quotation-mark *char quotation-mark
* <p>
* char = unescaped /
* escape (
* %x22 / ; " quotation mark U+0022
* %x5C / ; \ reverse solidus U+005C
* %x2F / ; / solidus U+002F
* %x62 / ; b backspace U+0008
* %x66 / ; f form feed U+000C
* %x6E / ; n line feed U+000A
* %x72 / ; r carriage return U+000D
* %x74 / ; t tab U+0009
* %x75 4HEXDIG ) ; uXXXX U+XXXX
* <p>
* escape = %x5C ; \
* <p>
* quotation-mark = %x22 ; "
* <p>
* unescaped = %x20-21 / %x23-5B / %x5D-10FFFF
*/
private static void printString(StringBuilder sb, CharSequence string) {
sb.append('"');
for (int i = 0; i < string.length(); i++) {
char ch = string.charAt(i);
switch (ch) {
case '"' -> sb.append('\\').append('"');
case '\\' -> sb.append('\\').append('\\');
case '/' -> sb.append('\\').append('/');
case '\b' -> sb.append('\\').append('b');
case '\f' -> sb.append('\\').append('f');
case '\n' -> sb.append('\\').append('n');
case '\r' -> sb.append('\\').append('r');
case '\t' -> sb.append('\\').append('t');
default -> {
if (isUnescaped(ch)) {
sb.append(ch);
} else {
sb.append("\\u").append(HexFormat.of().toHexDigits(ch, 4));
}
}
}
}
sb.append('"');
}
/**
* Custom exception for JSON parsing errors.
*/
public static final class ParseException extends RuntimeException {
/**
* Creates a new ParseException with the specified message.
*
* @param message The error message
*/
public ParseException(String message) {
super(message);
}
/**
* Creates a new ParseException with the specified message and cause.
*
* @param message The error message
* @param cause The cause of the error
*/
public ParseException(String message, Throwable cause) {
super(message, cause);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment