();
+ 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.parseString().value();
+ this.skipWhitespace();
+ this.expect(COLON);
+ this.skipWhitespace();
+
+ var value = this.readElement();
+ map.put(key, value);
+ this.skipWhitespace();
+
+ if (this.peek() == END_OBJECT) {
+ this.position++;
+ break;
+ }
+
+ this.expect(COMMA);
+ }
+
+ return new ObjectElement(map);
+ }
+
+ private boolean isExponent(char character) {
+ return character == Character.toLowerCase(EXPONENT)
+ || character == Character.toUpperCase(EXPONENT);
+ }
+
+ private char peek() throws ParsingException {
+ if (this.position >= this.input.length()) {
+ throw new ParsingException(this.input, "Unexpected end of input!", this.position);
+ }
+
+ return this.input.charAt(this.position);
+ }
+
+ private void expect(char character) throws ParsingException {
+ if (this.peek() != character) {
+ throw new ParsingException(this.input, "Expected '" + character + "', got '" + this.peek() + "'!", this.position);
+ }
+
+ this.position++;
+ }
+
+ private void skipWhitespace() {
+ while (this.position < this.input.length() && isWhitespace(this.input.charAt(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..dec5712
--- /dev/null
+++ b/json/src/main/java/alpine/json/JsonWriter.java
@@ -0,0 +1,81 @@
+package alpine.json;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+
+import static alpine.json.JsonUtility.*;
+
+@ApiStatus.Internal
+final class JsonWriter {
+ private static final String ESCAPED = ""
+ + QUOTE + BACKSLASH + BACKSPACE + FORM_FEED
+ + LINE_FEED + CARRIAGE_RETURN + TAB;
+
+ String write(Element value, Json.Formatting formatting) {
+ var builder = new StringBuilder();
+
+ switch (value) {
+ case NullElement ignored -> 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);
+ };
+
+ return builder.toString();
+ }
+
+ 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);
+
+ for (var character : string.toCharArray()) {
+ if (CHARACTER_TO_ESCAPE.containsKey(character)) {
+ builder.append(BACKSLASH).append(CHARACTER_TO_ESCAPE.get(character));
+ } else if (Character.isISOControl(character)) {
+ builder.append(String.format("\\%c%04X", UNICODE_ESCAPE, (int) character));
+ } else {
+ builder.append(character);
+ }
+ }
+
+ builder.append(QUOTE);
+ }
+
+ private void writeArray(StringBuilder builder, ArrayElement element, Json.Formatting formatting) {
+ builder
+ .append(BEGIN_ARRAY)
+ .append(element.stream()
+ .map(value -> this.write(value, formatting))
+ .collect(Collectors.joining(formatting.comma())))
+ .append(END_ARRAY);
+ }
+
+ private void writeObject(StringBuilder builder, ObjectElement element, Json.Formatting formatting) {
+ var firstElement = new AtomicBoolean(true);
+ builder.append(BEGIN_OBJECT);
+
+ element.each((key, value) -> {
+ builder.append(firstElement.get() ? "" : formatting.comma());
+ this.writeString(builder, key);
+ firstElement.set(false);
+ builder.append(formatting.colon()).append(this.write(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..57fe3c9
--- /dev/null
+++ b/json/src/main/java/alpine/json/ObjectElement.java
@@ -0,0 +1,142 @@
+package alpine.json;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.HashMap;
+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 Element get(String key, Element fallback) {
+ return this.elements.getOrDefault(key, 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 has(String key) {
+ if (key == null) throw new IllegalArgumentException("Key cannot be null!");
+ return this.elements.containsKey(key);
+ }
+
+ public boolean has(Element value) {
+ if (value == null) throw new IllegalArgumentException("Value cannot be null!");
+ return this.elements.containsValue(value);
+ }
+
+ public boolean has(Boolean value) {
+ return this.has(Element.bool(value));
+ }
+
+ public boolean has(Number value) {
+ return this.has(Element.number(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.has(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