();
+ this.expect(BEGIN_OBJECT);
+ this.skipWhitespace();
+
+ if (this.peek() == END_OBJECT) {
+ this.position++;
+ return new ObjectElement(map);
+ }
+
+ while (true) {
+ this.skipWhitespace();
+
+ if (this.peek() != QUOTE) {
+ throw new ParsingException(this.input, "Expected string for object key!", this.position);
+ }
+
+ var key = this.parseRawString();
+ this.skipWhitespace();
+ this.expect(COLON);
+ this.skipWhitespace();
+
+ map.put(key, this.readElement());
+ this.skipWhitespace();
+
+ if (this.peek() == END_OBJECT) {
+ this.position++;
+ break;
+ }
+
+ this.expect(COMMA);
+ }
+
+ return new ObjectElement(map);
+ }
+
+ private char peek() throws ParsingException {
+ if (this.position >= this.characters.length) {
+ throw new ParsingException(this.input, "Unexpected end of input!", this.position);
+ }
+
+ return this.characters[this.position];
+ }
+
+ private void expect(char character) throws ParsingException {
+ var actualCharacter = this.peek();
+
+ if (actualCharacter != character) {
+ throw new ParsingException(this.input, "Expected '" + character + "', got '" + actualCharacter + "'!", this.position);
+ }
+
+ this.position++;
+ }
+
+ private void skipWhitespace() {
+ while (this.position < this.characters.length && isWhitespace(this.characters[this.position])) {
+ this.position++;
+ }
+ }
+}
diff --git a/json/src/main/java/alpine/json/JsonUtility.java b/json/src/main/java/alpine/json/JsonUtility.java
new file mode 100644
index 0000000..3ab926a
--- /dev/null
+++ b/json/src/main/java/alpine/json/JsonUtility.java
@@ -0,0 +1,79 @@
+package alpine.json;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.Map;
+
+/**
+ * Represents constants internal to the JSON parser.
+ *
+ * Includes things like which characters to escape within a string.
+ */
+@ApiStatus.Internal
+interface JsonUtility {
+ static boolean isControl(char character) {
+ return character <= 0x1F;
+ }
+
+ static boolean isWhitespace(char character) {
+ return character == SPACE
+ || character == TAB
+ || character == LINE_FEED
+ || character == CARRIAGE_RETURN;
+ }
+
+ // Structural
+ char BEGIN_OBJECT = '{';
+ char END_OBJECT = '}';
+ char BEGIN_ARRAY = '[';
+ char END_ARRAY = ']';
+ char COMMA = ',';
+ char COLON = ':';
+
+ // Strings
+ char QUOTE = '"';
+ char BACKSLASH = '\\';
+ char SLASH = '/';
+ char UNICODE_ESCAPE = 'u';
+
+ // Numbers
+ char PLUS = '+';
+ char MINUS = '-';
+ char EXPONENT = 'e';
+ char BEGIN_DECIMAL = '.';
+
+ // Whitespace
+ char SPACE = ' ';
+ char TAB = '\t';
+ char LINE_FEED = '\n';
+ char CARRIAGE_RETURN = '\r';
+
+ // Literals
+ String NULL = "null";
+ String TRUE = "true";
+ String FALSE = "false";
+
+ // Other
+ char BACKSPACE = '\b';
+ char FORM_FEED = '\f';
+
+ // Escaping
+ Map CHARACTER_TO_ESCAPE = Map.of(
+ QUOTE, QUOTE,
+ BACKSLASH, BACKSLASH,
+ BACKSPACE, 'b',
+ FORM_FEED, 'f',
+ LINE_FEED, 'n',
+ CARRIAGE_RETURN, 'r',
+ TAB, 't');
+
+ Map ESCAPE_TO_CHARACTER = Map.of(
+ 'b', BACKSPACE,
+ 'f', FORM_FEED,
+ 'n', LINE_FEED,
+ 'r', CARRIAGE_RETURN,
+ 't', TAB,
+ QUOTE, QUOTE,
+ BACKSLASH, BACKSLASH,
+ SLASH, SLASH);
+}
diff --git a/json/src/main/java/alpine/json/JsonWriter.java b/json/src/main/java/alpine/json/JsonWriter.java
new file mode 100644
index 0000000..ef04d26
--- /dev/null
+++ b/json/src/main/java/alpine/json/JsonWriter.java
@@ -0,0 +1,126 @@
+package alpine.json;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import static alpine.json.JsonUtility.*;
+
+@ApiStatus.Internal
+final class JsonWriter {
+ private static final char[] HEX_CHARACTERS = "0123456789ABCDEF".toCharArray();
+
+ String write(Element value, Json.Formatting formatting) {
+ // pre-size string builder for performance
+ var builder = new StringBuilder(switch (value) {
+ case ObjectElement element -> 64 * element.length();
+ case ArrayElement element -> 16 * element.length();
+ default -> 32;
+ });
+
+ this.write(builder, value, formatting);
+ return builder.toString();
+ }
+
+ private void write(StringBuilder builder, Element value, Json.Formatting formatting) {
+ switch (value) {
+ case NullElement _ -> builder.append(NULL);
+ case BooleanElement element -> builder.append(element.value() ? TRUE : FALSE);
+ case NumberElement element -> this.writeNumber(builder, element.value());
+ case StringElement element -> this.writeString(builder, element.value());
+ case ArrayElement element -> this.writeArray(builder, element, formatting);
+ case ObjectElement element -> this.writeObject(builder, element, formatting);
+ }
+ }
+
+ private void writeNumber(StringBuilder builder, double value) {
+ if (Double.isNaN(value) || Double.isInfinite(value)) {
+ throw new IllegalArgumentException("NaN and infinite numbers are not allowed!");
+ }
+
+ if (value == Math.rint(value)) {
+ builder.append((long) value);
+ } else {
+ builder.append(value);
+ }
+ }
+
+ private void writeString(StringBuilder builder, String string) {
+ builder.append(QUOTE);
+
+ var start = 0;
+ var length = string.length();
+
+ for (var index = 0; index < length; index++) {
+ var character = string.charAt(index);
+
+ // fast path: safe character
+ if (character != '"' && character != '\\' && !Character.isISOControl(character)) {
+ continue;
+ }
+
+ if (index > start) {
+ builder.append(string, start, index);
+ }
+
+ switch (character) {
+ case '"' -> builder.append("\\\"");
+ case '\\' -> builder.append("\\\\");
+ case '\b' -> builder.append("\\b");
+ case '\f' -> builder.append("\\f");
+ case '\n' -> builder.append("\\n");
+ case '\r' -> builder.append("\\r");
+ case '\t' -> builder.append("\\t");
+
+ default -> {
+ builder.append("\\u");
+ builder.append(HEX_CHARACTERS[(character >> 12) & 0xF]);
+ builder.append(HEX_CHARACTERS[(character >> 8) & 0xF]);
+ builder.append(HEX_CHARACTERS[(character >> 4) & 0xF]);
+ builder.append(HEX_CHARACTERS[character & 0xF]);
+ }
+ }
+
+ start = index + 1;
+ }
+
+ if (start < length) {
+ builder.append(string, start, length);
+ }
+
+ builder.append(QUOTE);
+ }
+
+ private void writeArray(StringBuilder builder, ArrayElement element, Json.Formatting formatting) {
+ builder.append(BEGIN_ARRAY);
+ var firstElement = true;
+
+ for (var value : element) {
+ if (!firstElement) {
+ builder.append(formatting.comma());
+ }
+
+ this.write(builder, value, formatting);
+ firstElement = false;
+ }
+
+ builder.append(END_ARRAY);
+ }
+
+ private void writeObject(StringBuilder builder, ObjectElement element, Json.Formatting formatting) {
+ builder.append(BEGIN_OBJECT);
+ var firstElement = new boolean[] { true };
+
+ element.each((key, value) -> {
+ if (firstElement[0]) {
+ firstElement[0] = false;
+ } else {
+ builder.append(formatting.comma());
+ }
+
+ this.writeString(builder, key);
+ builder.append(formatting.colon());
+ this.write(builder, value, formatting);
+ });
+
+ builder.append(END_OBJECT);
+ }
+}
diff --git a/json/src/main/java/alpine/json/NullElement.java b/json/src/main/java/alpine/json/NullElement.java
new file mode 100644
index 0000000..7ba683e
--- /dev/null
+++ b/json/src/main/java/alpine/json/NullElement.java
@@ -0,0 +1,21 @@
+package alpine.json;
+
+/**
+ * A JSON element which represents the absence of a value.
+ *
+ * This element can only be represented as {@code null} in encoded form.
+ * @see RFC 8259
+ * @author mudkip
+ */
+public final class NullElement implements Element {
+ static final NullElement INSTANCE = new NullElement();
+
+ private NullElement() {
+
+ }
+
+ @Override
+ public String toString() {
+ return Json.write(this, Json.Formatting.PRETTY);
+ }
+}
diff --git a/json/src/main/java/alpine/json/NumberElement.java b/json/src/main/java/alpine/json/NumberElement.java
new file mode 100644
index 0000000..163a99e
--- /dev/null
+++ b/json/src/main/java/alpine/json/NumberElement.java
@@ -0,0 +1,34 @@
+package alpine.json;
+
+/**
+ * A JSON element which can represent both integers and fractional numbers.
+ * @see RFC 8259
+ * @author mudkip
+ */
+public final class NumberElement implements Element {
+ private final double value;
+
+ NumberElement(double value) {
+ this.value = value;
+ }
+
+ @Override
+ public int hashCode() {
+ return Double.hashCode(this.value);
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ return this == object || (object instanceof NumberElement element
+ && element.value == this.value);
+ }
+
+ @Override
+ public String toString() {
+ return Json.write(this, Json.Formatting.PRETTY);
+ }
+
+ public double value() {
+ return this.value;
+ }
+}
diff --git a/json/src/main/java/alpine/json/ObjectElement.java b/json/src/main/java/alpine/json/ObjectElement.java
new file mode 100644
index 0000000..ff67062
--- /dev/null
+++ b/json/src/main/java/alpine/json/ObjectElement.java
@@ -0,0 +1,238 @@
+package alpine.json;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+/**
+ * A JSON element which stores a collection of key-value pairs where the keys are strings.
+ * @see RFC 8259
+ * @author mudkip
+ */
+public final class ObjectElement implements Element {
+ private final Map elements;
+
+ ObjectElement() {
+ this(new LinkedHashMap<>());
+ }
+
+ ObjectElement(Map elements) {
+ this.elements = elements;
+ }
+
+ @Override
+ public int hashCode() {
+ return this.elements.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ return this == object || (object instanceof ObjectElement element
+ && Objects.equals(this.elements, element.elements));
+ }
+
+ @Override
+ public String toString() {
+ return Json.write(this, Json.Formatting.PRETTY);
+ }
+
+ public Stream> stream() {
+ return this.elements.entrySet().stream();
+ }
+
+ public boolean empty() {
+ return this.length() < 1;
+ }
+
+ public int length() {
+ return this.elements.size();
+ }
+
+ public @Nullable Element get(String key) {
+ return this.elements.get(key);
+ }
+
+ public @Nullable String getString(String key) {
+ return this.getString(key, null);
+ }
+
+ public @Nullable Number getNumber(String key) {
+ return this.getNumber(key, null);
+ }
+
+ public byte getByte(String key) {
+ return (byte) this.getNumber(key, 0);
+ }
+
+ public short getShort(String key) {
+ return (short) this.getNumber(key, 0);
+ }
+
+ public int getInteger(String key) {
+ return (int) this.getNumber(key, 0);
+ }
+
+ public long getLong(String key) {
+ return (long) this.getNumber(key, 0);
+ }
+
+ public float getFloat(String key) {
+ return (float) this.getNumber(key, 0.0F);
+ }
+
+ public double getDouble(String key) {
+ return (double) this.getNumber(key, 0.0D);
+ }
+
+ public @Nullable Boolean getBoolean(String key) {
+ return this.elements.get(key) instanceof BooleanElement element
+ ? element.value() : null;
+ }
+
+ public @Nullable Element get(String key, Element fallback) {
+ return this.elements.getOrDefault(key, fallback);
+ }
+
+ public String getString(String key, String fallback) {
+ return this.elements.get(key) instanceof StringElement element
+ ? element.value() : fallback;
+ }
+
+ public Number getNumber(String key, Number fallback) {
+ return this.elements.get(key) instanceof NumberElement element
+ ? element.value() : fallback;
+ }
+
+ public byte getByte(String key, byte fallback) {
+ return this.elements.get(key) instanceof NumberElement element
+ ? (byte) element.value() : fallback;
+ }
+
+ public short getShort(String key, short fallback) {
+ return this.elements.get(key) instanceof NumberElement element
+ ? (short) element.value() : fallback;
+ }
+
+ public int getInteger(String key, int fallback) {
+ return this.elements.get(key) instanceof NumberElement element
+ ? (int) element.value() : fallback;
+ }
+
+ public long getLong(String key, long fallback) {
+ return this.elements.get(key) instanceof NumberElement element
+ ? (long) element.value() : fallback;
+ }
+
+ public float getFloat(String key, float fallback) {
+ return this.elements.get(key) instanceof NumberElement element
+ ? (float) element.value() : fallback;
+ }
+
+ public double getDouble(String key, double fallback) {
+ return this.elements.get(key) instanceof NumberElement element
+ ? element.value() : fallback;
+ }
+
+ public boolean getBoolean(String key, boolean fallback) {
+ return this.elements.get(key) instanceof BooleanElement element
+ ? element.value() : fallback;
+ }
+
+ @SuppressWarnings("unchecked")
+ public T get(String key, Class clazz, T fallback) {
+ var element = this.elements.get(key);
+
+ if (clazz.isInstance(element)) {
+ return (T) element;
+ } else {
+ return fallback;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ public T expect(String key, Class clazz) {
+ var element = this.elements.get(key);
+
+ if (element == null) {
+ throw new AssertionError("Expected an element for \"" + key + "\"!");
+ } else if (!clazz.isInstance(element)) {
+ throw new AssertionError("Expected element to be a " + clazz.getSimpleName() + "!");
+ } else return (T) element;
+ }
+
+ public boolean hasKey(String key) {
+ if (key == null) throw new IllegalArgumentException("Key cannot be null!");
+ return this.elements.containsKey(key);
+ }
+
+ public boolean hasValue(Element value) {
+ if (value == null) throw new IllegalArgumentException("Value cannot be null!");
+ return this.elements.containsValue(value);
+ }
+
+ public boolean hasString(String value) {
+ return this.hasValue(Element.string(value));
+ }
+
+ public boolean hasValue(Number value) {
+ return this.hasValue(Element.number(value));
+ }
+
+ public boolean hasValue(Boolean value) {
+ return this.hasValue(Element.bool(value));
+ }
+
+ public void each(BiConsumer consumer) {
+ if (consumer == null) {
+ throw new IllegalArgumentException("Consumer cannot be null!");
+ }
+
+ this.elements.forEach(consumer);
+ }
+
+ public ObjectElement set(String key, Element value) {
+ if (key == null) {
+ throw new IllegalArgumentException("Key cannot be null!");
+ } else if (value == null) {
+ throw new IllegalArgumentException("Value cannot be null!");
+ }
+
+ return this.copy(map -> map.put(key, value));
+ }
+
+ public ObjectElement set(String key, boolean value) {
+ return this.set(key, Element.bool(value));
+ }
+
+ public ObjectElement set(String key, Number value) {
+ return this.set(key, Element.number(value));
+ }
+
+ public ObjectElement set(String key, String value) {
+ return this.set(key, Element.string(value));
+ }
+
+ public ObjectElement remove(String key) {
+ if (!this.hasKey(key)) {
+ throw new IllegalStateException("Key \"" + key + "\" is not present!");
+ }
+
+ return this.copy(map -> map.remove(key));
+ }
+
+ public ObjectElement clear() {
+ return this.copy(Map::clear);
+ }
+
+ public ObjectElement copy(Consumer