From 5a5d1492a57199474a143eeb4d28f9061465afd7 Mon Sep 17 00:00:00 2001 From: jonah Date: Wed, 24 Jan 2024 17:40:42 +0100 Subject: [PATCH] jq path wip --- .../java/eu/jonahbauer/json/JsonPath.java | 249 ++++++++++++++++++ .../jonahbauer/json/parser/JsonPathTest.java | 75 ++++++ 2 files changed, 324 insertions(+) create mode 100644 src/main/java/eu/jonahbauer/json/JsonPath.java create mode 100644 src/test/java/eu/jonahbauer/json/parser/JsonPathTest.java diff --git a/src/main/java/eu/jonahbauer/json/JsonPath.java b/src/main/java/eu/jonahbauer/json/JsonPath.java new file mode 100644 index 0000000..e4c7d1f --- /dev/null +++ b/src/main/java/eu/jonahbauer/json/JsonPath.java @@ -0,0 +1,249 @@ +package eu.jonahbauer.json; + +import lombok.EqualsAndHashCode; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +@EqualsAndHashCode +public final class JsonPath { + public static final JsonPath EMPTY = new JsonPath(Collections.emptyList()); + + private final @NotNull List<@NotNull Component> components; + + public static @NotNull JsonPath parse(@NotNull String path) { + var components = new ArrayList(); + + var reader = new StringReader(path); + + if (reader.hasNext()) { + if (reader.next() == '.' && !reader.hasNext()) return JsonPath.EMPTY; + reader.pushback(); + } + + do { + if (reader.next() != '.') throw unexpectedCharacter(reader.current(), "'.'"); + + components.add(switch (reader.next()) { + // object access with string + case '"' -> { + reader.pushback(); + yield new Component.ObjectAccess(readString(reader)); + } + case '[' -> { + var out = switch (reader.next()) { + // object access with string + case '"' -> { + reader.pushback(); + yield new Component.ObjectAccess(readString(reader)); + } + // array access with index + case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> { + var negative = reader.current() == '-'; + if (negative) reader.pushback(); + var index = readInteger(reader); + yield new Component.ArrayAccess(index, negative && index != 0); + } + default -> throw unexpectedCharacter(reader.current(), "'\"' or '1'-'9'"); + }; + if (reader.next() != ']') throw unexpectedCharacter(reader.current(), "']'"); + yield out; + } + // object access with identifier + default -> { + var chr = reader.current(); + if (chr == '_' || 'a' <= chr && chr <= 'z' || 'A' <= chr && chr <= 'Z') { + yield new Component.ObjectAccess(readIdentifier(reader)); + } else { + throw unexpectedCharacter(chr, "one of '\"', '[', '_', 'a'-'z' or 'A'-'Z'"); + } + } + }); + } while (reader.hasNext()); + + return new JsonPath(Collections.unmodifiableList(components)); + } + + private JsonPath(@NotNull List<@NotNull Component> components) { + this.components = components; + } + + public @Nullable JsonValue select(@Nullable JsonValue value) { + for (Component component : components) { + value = component.select(value); + } + return value; + } + + public @NotNull JsonPath resolve(@NotNull String key) { + var components = new ArrayList(this.components.size() + 1); + components.addAll(this.components); + components.add(new Component.ObjectAccess(key)); + return new JsonPath(Collections.unmodifiableList(components)); + } + + public @NotNull JsonPath resolve(int index) { + var components = new ArrayList(this.components.size() + 1); + components.addAll(this.components); + components.add(new Component.ArrayAccess(Math.abs(index), index < 0)); + return new JsonPath(Collections.unmodifiableList(components)); + } + + private sealed interface Component { + @Nullable JsonValue select(@Nullable JsonValue value); + + record ArrayAccess(int index, boolean end) implements Component { + + @Override + public @Nullable JsonValue select(@Nullable JsonValue value) { + if (value instanceof JsonArray array) { + if (end) return array.get(array.size() - index); + return array.get(index); + } else { + throw new JsonProcessingException("unexpected type"); + } + } + + @Override + public String toString() { + return "[" + index + "]"; + } + } + + record ObjectAccess(@NotNull String key) implements Component { + @Override + public @Nullable JsonValue select(@Nullable JsonValue value) { + if (value instanceof JsonObject object) { + return object.get(key); + } else { + throw new JsonProcessingException("unexpected type"); + } + } + + @Override + public String toString() { + return ".[\"" + JsonString.quote(key) + "\"]"; + } + } + } + + private static int readInteger(@NotNull StringReader reader) { + var i = reader.next() - '0'; + + while (reader.hasNext()) { + var chr = reader.next(); + if (chr < '0' || chr > '9') { + reader.pushback(); + break; + } else if (i == 0) { + throw unexpectedCharacter(chr, "']'"); + } else { + i = Math.multiplyExact(i, 10); + i = Math.addExact(i, chr - '0'); + } + } + + return i; + } + + private static @NotNull String readString(@NotNull StringReader reader) { + var out = new StringBuilder(); + if (reader.next() != '"') throw new IllegalStateException(); + + while (true) { + char chr = reader.next(); + if (chr == '"') { + return out.toString(); + } else if (chr == '\\') { + out.append(switch (reader.next()) { + case '"' -> '"'; + case '\\' -> '\\'; + case '/' -> '/'; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'u' -> { + var u0 = toHex(reader.next()); + var u1 = toHex(reader.next()); + var u2 = toHex(reader.next()); + var u3 = toHex(reader.next()); + + yield (char) ((u0 << 12) + (u1 << 8) + (u2 << 4) + u3); + } + default -> throw unexpectedCharacter(reader.current(), "one of '\"', '\\', '/', 'b', 'f', 'n', 'r', 't' or 'u'"); + }); + } else if (chr < 32) { + throw new JsonProcessingException("unescaped control character in string literal"); + } else { + out.append(chr); + } + } + } + + private static int toHex(char chr) { + if ('0' <= chr && chr <= '9') { + return chr - '0'; + } else if ('a' <= chr && chr <= 'f') { + return 11 + chr - 'a'; + } else if ('A' <= chr && chr <= 'F') { + return 11 + chr - 'A'; + } else { + throw unexpectedCharacter(chr, "one of '0'-'9', 'a'-'f' or 'A'-'F'"); + } + } + + private static @NotNull String readIdentifier(@NotNull StringReader reader) { + var out = new StringBuilder(); + out.append(reader.next()); + + while (reader.hasNext()) { + var chr = reader.next(); + if (chr == '_' || 'a' <= chr && chr <= 'z' || 'A' <= chr && chr <= 'Z' || '0' <= chr && chr <= '9') { + out.append(chr); + } else { + throw unexpectedCharacter(chr, "one of '.', '_', 'a'-'z', 'A'-'Z' or '0'-'9'"); + } + } + + return out.toString(); + } + + private static JsonProcessingException unexpectedCharacter(char character, String expected) { + return new JsonProcessingException("unexpected character '" + character + "' (expected " + expected + ")"); + } + + private static class StringReader { + private final String string; + private final int length; + private int index; + + public StringReader(String string) { + this.string = string; + this.length = string.length(); + } + + public void pushback() { + if (index == 0) throw new IllegalStateException(); + index--; + } + + public char next() { + if (index >= length) { + throw new JsonProcessingException("unexpected end of string"); + } + return string.charAt(index++); + } + + public char current() { + return string.charAt(index - 1); + } + + public boolean hasNext() { + return index < length; + } + } + +} diff --git a/src/test/java/eu/jonahbauer/json/parser/JsonPathTest.java b/src/test/java/eu/jonahbauer/json/parser/JsonPathTest.java new file mode 100644 index 0000000..103e994 --- /dev/null +++ b/src/test/java/eu/jonahbauer/json/parser/JsonPathTest.java @@ -0,0 +1,75 @@ +package eu.jonahbauer.json.parser; + +import eu.jonahbauer.json.*; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class JsonPathTest { + private final JsonValue array = new JsonArray(List.of( + new JsonNumber(1), + new JsonNumber(2), + new JsonObject(Map.of( + "bar", JsonBoolean.FALSE + )) + )); + + private final JsonValue object = new JsonObject(Map.of( + "foo", array, + "bar", new JsonString("baz"), + "quack quack", new JsonString("foobar") + ));; + + + @Test + void parseEmptyShouldFail() { + assertThrows(JsonProcessingException.class, () -> JsonPath.parse("")); + } + + @Test + void parseIdentity() { + var path = JsonPath.parse("."); + assertEquals(object, path.select(object)); + } + + @Test + void parseArrayAccess() { + var path = JsonPath.parse(".[0]"); + assertEquals(new JsonNumber(1), path.select(array)); + } + + @Test + void parseArrayAccessWithLeadingZero() { + assertThrows(JsonProcessingException.class, () -> JsonPath.parse(".[01]")); + } + + @Test + void parseObjectAccessWithIdentifier() { + var path = JsonPath.parse(".bar"); + assertEquals(new JsonString("baz"), path.select(object)); + } + + @Test + void parseObjectAccessWithString() { + var path = JsonPath.parse(".\"quack quack\""); + assertEquals(new JsonString("foobar"), path.select(object)); + } + + @Test + void parseObjectAccessWithArraySyntax() { + var path = JsonPath.parse(".[\"quack quack\"]"); + assertEquals(new JsonString("foobar"), path.select(object)); + + assertThrows(JsonProcessingException.class, () -> JsonPath.parse(".[quack quack]")); + } + + @Test + void parseChainedAccess() { + var path = JsonPath.parse(".foo[2].bar"); + assertEquals(JsonBoolean.FALSE, path.select(object)); + } +}