diff --git a/json-java21-jsonpointer/pom.xml b/json-java21-jsonpointer/pom.xml
new file mode 100644
index 0000000..d0f9a3d
--- /dev/null
+++ b/json-java21-jsonpointer/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+
+
+ io.github.simbo1905.json
+ parent
+ 0.1.9
+
+
+ java.util.json.jsonpointer
+ jar
+ java.util.json Java21 Backport JSON Pointer
+ https://simbo1905.github.io/java.util.json.Java21/
+
+ scm:git:https://github.com/simbo1905/java.util.json.Java21.git
+ scm:git:git@github.com:simbo1905/java.util.json.Java21.git
+ https://github.com/simbo1905/java.util.json.Java21
+ HEAD
+
+ RFC 6901 JSON Pointer implementation for the java.util.json Java 21 backport; parses JSON Pointer expressions and resolves them against JSON documents.
+
+
+ UTF-8
+ 21
+
+
+
+
+ io.github.simbo1905.json
+ java.util.json
+ ${project.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 21
+
+ -Xlint:all
+ -Werror
+ -Xdiags:verbose
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ -ea
+
+
+
+
+
diff --git a/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointer.java b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointer.java
new file mode 100644
index 0000000..58637be
--- /dev/null
+++ b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointer.java
@@ -0,0 +1,249 @@
+package json.java21.jsonpointer;
+
+import jdk.sandbox.java.util.json.JsonArray;
+import jdk.sandbox.java.util.json.JsonObject;
+import jdk.sandbox.java.util.json.JsonValue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+/// RFC 6901 JSON Pointer — an exact-address path into a JSON document.
+///
+/// A JSON Pointer is a Unicode string of zero or more reference tokens, each
+/// prefixed by `/`. The empty string `""` identifies the root document.
+/// Each token names an object member or an array index.
+///
+/// Escaping (RFC 6901 §3):
+///
+/// - `~1` → `/`
+/// - `~0` → `~`
+///
+/// The replacement order matters: `~1` is decoded before `~0` so that `~01`
+/// decodes to the literal string `~1` rather than `/`.
+///
+/// Usage:
+/// ```java
+/// JsonValue doc = Json.parse(jsonText);
+/// JsonValue value = JsonPointer.parse("/foo/0").resolve(doc);
+/// boolean found = JsonPointer.parse("/foo/0").exists(doc);
+/// ```
+///
+/// @see RFC 6901 — JSON Pointer
+public final class JsonPointer {
+
+ private static final Logger LOG = Logger.getLogger(JsonPointer.class.getName());
+
+ private final String raw;
+ private final List tokens;
+
+ private JsonPointer(String raw, List tokens) {
+ this.raw = raw;
+ this.tokens = List.copyOf(tokens);
+ }
+
+ /// Parses a JSON Pointer string.
+ ///
+ /// The empty string `""` is valid and identifies the root document. Any
+ /// non-empty pointer must begin with `/`. Escape sequences must be
+ /// well-formed: `~` must be followed by `0` or `1`.
+ ///
+ /// @param pointer the JSON Pointer string; must not be `null`
+ /// @return a `JsonPointer` instance
+ /// @throws NullPointerException if `pointer` is `null`
+ /// @throws JsonPointerSyntaxException if `pointer` is not a valid JSON Pointer
+ public static JsonPointer parse(String pointer) {
+ Objects.requireNonNull(pointer, "pointer must not be null");
+ LOG.fine(() -> "parsing JSON Pointer: \"" + pointer + "\"");
+
+ if (pointer.isEmpty()) {
+ return new JsonPointer(pointer, List.of());
+ }
+
+ if (pointer.charAt(0) != '/') {
+ throw new JsonPointerSyntaxException(
+ "non-empty JSON Pointer must start with '/'", pointer);
+ }
+
+ // Split on '/' — the first char is '/' so the first segment is always empty;
+ // skip it by starting at index 1.
+ final var segments = pointer.substring(1).split("/", -1);
+ final var tokenList = new ArrayList(segments.length);
+ for (final var segment : segments) {
+ tokenList.add(decodeToken(segment, pointer));
+ }
+
+ LOG.fine(() -> "parsed " + tokenList.size() + " token(s) from \"" + pointer + "\"");
+ return new JsonPointer(pointer, tokenList);
+ }
+
+ /// Returns the decoded reference tokens of this pointer.
+ ///
+ /// The returned list is unmodifiable. An empty list means the pointer
+ /// identifies the root document.
+ ///
+ /// @return immutable list of decoded reference tokens
+ public List tokens() {
+ return tokens;
+ }
+
+ /// Resolves this pointer against `document`, returning the identified value.
+ ///
+ /// @param document the JSON document to resolve against; must not be `null`
+ /// @return the `JsonValue` identified by this pointer
+ /// @throws NullPointerException if `document` is `null`
+ /// @throws JsonPointerResolutionException if no value exists at this pointer
+ public JsonValue resolve(JsonValue document) {
+ Objects.requireNonNull(document, "document must not be null");
+ LOG.fine(() -> "resolving pointer \"" + raw + "\" against document");
+
+ return resolveInternal(document).orElseThrow(
+ () -> new JsonPointerResolutionException(
+ "pointer does not identify an existing value", raw, null));
+ }
+
+ /// Returns `true` if this pointer identifies an existing value in `document`.
+ ///
+ /// @param document the JSON document; must not be `null`
+ /// @return `true` when the pointed-to value exists, `false` otherwise
+ /// @throws NullPointerException if `document` is `null`
+ public boolean exists(JsonValue document) {
+ Objects.requireNonNull(document, "document must not be null");
+ return resolveInternal(document).isPresent();
+ }
+
+ /// Returns the original JSON Pointer string as passed to [#parse(String)].
+ ///
+ /// The returned value is a valid JSON Pointer and satisfies:
+ /// `JsonPointer.parse(p.toString()).equals(p)`.
+ ///
+ /// @return the canonical pointer string
+ @Override
+ public String toString() {
+ return raw;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj instanceof JsonPointer other && raw.equals(other.raw);
+ }
+
+ @Override
+ public int hashCode() {
+ return raw.hashCode();
+ }
+
+ // -------------------------------------------------------------------------
+ // Internal helpers
+ // -------------------------------------------------------------------------
+
+ /// Attempts to resolve this pointer; returns empty if any step fails.
+ private Optional resolveInternal(JsonValue document) {
+ var current = document;
+ for (final var token : tokens) {
+ final var next = step(current, token);
+ if (next.isEmpty()) {
+ LOG.fine(() -> "resolution failed at token \"" + token
+ + "\" for pointer \"" + raw + "\"");
+ return Optional.empty();
+ }
+ current = next.get();
+ }
+ return Optional.of(current);
+ }
+
+ /// Applies a single reference token to `node`, returning the resulting value
+ /// or empty if the step cannot be taken.
+ private static Optional step(JsonValue node, String token) {
+ return switch (node) {
+ case JsonObject obj -> {
+ final var value = obj.members().get(token);
+ yield Optional.ofNullable(value);
+ }
+ case JsonArray arr -> {
+ final var elements = arr.elements();
+ final var index = parseArrayIndex(token, elements.size());
+ yield index >= 0 ? Optional.of(elements.get(index)) : Optional.empty();
+ }
+ default -> Optional.empty();
+ };
+ }
+
+ /// Parses an array reference token.
+ ///
+ /// Returns the non-negative index when the token is a valid non-negative
+ /// integer within bounds, or `-1` in every other case:
+ ///
+ /// - `-` is the special "append" token defined by RFC 6902; it is not a
+ /// valid resolution target in RFC 6901.
+ /// - Leading zeros are forbidden (e.g. `"01"` is invalid per RFC 6901 §4).
+ /// - Negative numbers are not permitted.
+ /// - Any non-numeric token applied to an array fails resolution.
+ ///
+ /// @param token the reference token
+ /// @param length the array length
+ /// @return the index, or `-1` on failure
+ private static int parseArrayIndex(String token, int length) {
+ if (token.isEmpty()) {
+ return -1;
+ }
+ // '-' is the RFC 6902 append sentinel; not resolvable in RFC 6901
+ if ("-".equals(token)) {
+ return -1;
+ }
+ // Leading zeros are forbidden for non-zero values
+ if (token.length() > 1 && token.charAt(0) == '0') {
+ return -1;
+ }
+ // Must be all digits
+ for (int i = 0; i < token.length(); i++) {
+ if (!Character.isDigit(token.charAt(i))) {
+ return -1;
+ }
+ }
+ try {
+ final var index = Integer.parseInt(token);
+ return (index >= 0 && index < length) ? index : -1;
+ } catch (NumberFormatException e) {
+ // Overflow — value too large to be a valid index
+ return -1;
+ }
+ }
+
+ /// Decodes a single reference token according to RFC 6901 §3.
+ ///
+ /// The replacement order is mandated by the RFC:
+ ///
+ /// 1. `~1` → `/`
+ /// 2. `~0` → `~`
+ ///
+ /// This order ensures that `~01` becomes `~1` (not `/`).
+ ///
+ /// @param token the raw (encoded) reference token segment
+ /// @param pointer the full pointer string (used in exception messages)
+ /// @return the decoded token
+ /// @throws JsonPointerSyntaxException if the token contains an invalid `~` escape
+ static String decodeToken(String token, String pointer) {
+ if (!token.contains("~")) {
+ return token;
+ }
+ // Validate: every '~' must be followed by '0' or '1'
+ for (int i = 0; i < token.length(); i++) {
+ if (token.charAt(i) == '~') {
+ if (i + 1 >= token.length()) {
+ throw new JsonPointerSyntaxException(
+ "invalid escape sequence: '~' at end of token", pointer);
+ }
+ final var next = token.charAt(i + 1);
+ if (next != '0' && next != '1') {
+ throw new JsonPointerSyntaxException(
+ "invalid escape sequence '~" + next + "' in token", pointer);
+ }
+ }
+ }
+ // Decode in the RFC-mandated order: ~1 first, then ~0
+ return token.replace("~1", "/").replace("~0", "~");
+ }
+}
diff --git a/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerResolutionException.java b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerResolutionException.java
new file mode 100644
index 0000000..80e2bb5
--- /dev/null
+++ b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerResolutionException.java
@@ -0,0 +1,58 @@
+package json.java21.jsonpointer;
+
+import java.io.Serial;
+
+/// Exception thrown when a JSON Pointer cannot be resolved against a document.
+///
+/// Resolution failures are distinct from parse failures. A pointer may be
+/// syntactically valid but still fail to resolve because:
+///
+/// - A referenced object member does not exist.
+/// - An array index is out of bounds.
+/// - An array token is not a valid non-negative integer.
+/// - A step traverses a primitive value (string, number, boolean, null) that
+/// has no children.
+///
+/// @see JsonPointerSyntaxException
+public final class JsonPointerResolutionException extends RuntimeException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final String pointer;
+ private final String failingToken;
+
+ /// Creates a new resolution exception.
+ ///
+ /// @param message human-readable description of the resolution failure
+ /// @param pointer the pointer that failed to resolve
+ /// @param failingToken the reference token that could not be applied, or
+ /// `null` if the whole pointer is the context
+ public JsonPointerResolutionException(String message, String pointer, String failingToken) {
+ super(formatMessage(message, pointer, failingToken));
+ this.pointer = pointer;
+ this.failingToken = failingToken;
+ }
+
+ /// Returns the pointer that failed to resolve.
+ ///
+ /// @return the pointer string
+ public String pointer() {
+ return pointer;
+ }
+
+ /// Returns the reference token that could not be applied, or `null` when not
+ /// applicable.
+ ///
+ /// @return the failing token, or `null`
+ public String failingToken() {
+ return failingToken;
+ }
+
+ private static String formatMessage(String message, String pointer, String failingToken) {
+ if (failingToken == null) {
+ return message + ": pointer=\"" + pointer + "\"";
+ }
+ return message + ": pointer=\"" + pointer + "\", token=\"" + failingToken + "\"";
+ }
+}
diff --git a/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerSyntaxException.java b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerSyntaxException.java
new file mode 100644
index 0000000..8a1edd1
--- /dev/null
+++ b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerSyntaxException.java
@@ -0,0 +1,40 @@
+package json.java21.jsonpointer;
+
+import java.io.Serial;
+
+/// Exception thrown when a JSON Pointer string cannot be parsed.
+///
+/// A non-empty JSON Pointer must start with `/`. Escape sequences must be
+/// well-formed: `~` must be followed by `0` or `1`; no other tilde sequences
+/// are permitted (RFC 6901 §3).
+///
+/// This is an unchecked exception because pointer syntax errors are typically
+/// programming errors discovered at development time.
+public final class JsonPointerSyntaxException extends RuntimeException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final String pointer;
+
+ /// Creates a new syntax exception with the given message and the pointer string
+ /// that was being parsed.
+ ///
+ /// @param message human-readable description of the syntax error
+ /// @param pointer the invalid pointer string
+ public JsonPointerSyntaxException(String message, String pointer) {
+ super(formatMessage(message, pointer));
+ this.pointer = pointer;
+ }
+
+ /// Returns the pointer string that failed to parse.
+ ///
+ /// @return the invalid pointer string
+ public String pointer() {
+ return pointer;
+ }
+
+ private static String formatMessage(String message, String pointer) {
+ return message + ": \"" + pointer + "\"";
+ }
+}
diff --git a/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerLoggingConfig.java b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerLoggingConfig.java
new file mode 100644
index 0000000..48a6bab
--- /dev/null
+++ b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerLoggingConfig.java
@@ -0,0 +1,42 @@
+package json.java21.jsonpointer;
+
+import org.junit.jupiter.api.BeforeAll;
+
+import java.util.Locale;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/// Base class for JSON Pointer tests that configures JUL logging from system properties.
+/// All test classes should extend this class to enable consistent logging behaviour.
+public class JsonPointerLoggingConfig {
+
+ @BeforeAll
+ static void enableJulDebug() {
+ final var root = Logger.getLogger("");
+ final var levelProp = System.getProperty("java.util.logging.ConsoleHandler.level");
+ var targetLevel = Level.INFO;
+ if (levelProp != null) {
+ try {
+ targetLevel = Level.parse(levelProp.trim());
+ } catch (IllegalArgumentException ex) {
+ try {
+ targetLevel = Level.parse(levelProp.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException ignored) {
+ Logger.getLogger(JsonPointerLoggingConfig.class.getName())
+ .warning(() -> "Unrecognized logging level from "
+ + "'java.util.logging.ConsoleHandler.level': " + levelProp);
+ }
+ }
+ }
+ if (root.getLevel() == null || root.getLevel().intValue() > targetLevel.intValue()) {
+ root.setLevel(targetLevel);
+ }
+ for (final Handler handler : root.getHandlers()) {
+ final var handlerLevel = handler.getLevel();
+ if (handlerLevel == null || handlerLevel.intValue() > targetLevel.intValue()) {
+ handler.setLevel(targetLevel);
+ }
+ }
+ }
+}
diff --git a/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java
new file mode 100644
index 0000000..5d6e56d
--- /dev/null
+++ b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java
@@ -0,0 +1,370 @@
+package json.java21.jsonpointer;
+
+import jdk.sandbox.java.util.json.Json;
+import jdk.sandbox.java.util.json.JsonArray;
+import jdk.sandbox.java.util.json.JsonNull;
+import jdk.sandbox.java.util.json.JsonObject;
+import jdk.sandbox.java.util.json.JsonValue;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.List;
+import java.util.logging.Logger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/// RFC 6901 JSON Pointer specification tests.
+///
+/// Section 5 of RFC 6901 defines an example document and a table of pointer
+/// expressions with their expected resolved values. Every entry in that table
+/// is covered here, plus additional edge-case tests required by the issue.
+///
+/// @see RFC 6901 §5
+class JsonPointerTest extends JsonPointerLoggingConfig {
+
+ private static final Logger LOG = Logger.getLogger(JsonPointerTest.class.getName());
+
+ // -------------------------------------------------------------------------
+ // RFC 6901 §5 example document
+ // -------------------------------------------------------------------------
+
+ private static final String RFC_DOCUMENT = """
+ {
+ "foo": ["bar", "baz"],
+ "": 0,
+ "a/b": 1,
+ "c%d": 2,
+ "e^f": 3,
+ "g|h": 4,
+ "i\\\\j": 5,
+ "k\\"l": 6,
+ " ": 7,
+ "m~n": 8
+ }
+ """;
+
+ private static JsonValue rfcDoc() {
+ return Json.parse(RFC_DOCUMENT);
+ }
+
+ // -------------------------------------------------------------------------
+ // RFC 6901 §5 example table — every row
+ // -------------------------------------------------------------------------
+
+ @Test
+ void emptyPointerResolvesToWholeDocument() {
+ LOG.info("TEST: emptyPointerResolvesToWholeDocument");
+ final var doc = rfcDoc();
+ final var result = JsonPointer.parse("").resolve(doc);
+ assertThat(result).isInstanceOf(JsonObject.class);
+ assertThat(result).isSameAs(doc);
+ }
+
+ @Test
+ void fooResolvesToArray() {
+ LOG.info("TEST: fooResolvesToArray");
+ final var result = JsonPointer.parse("/foo").resolve(rfcDoc());
+ assertThat(result).isInstanceOf(JsonArray.class);
+ assertThat(((JsonArray) result).elements()).hasSize(2);
+ }
+
+ @Test
+ void fooZeroResolvesToFirstArrayElement() {
+ LOG.info("TEST: fooZeroResolvesToFirstArrayElement");
+ final var result = JsonPointer.parse("/foo/0").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("\"bar\"");
+ }
+
+ @Test
+ void slashAloneResolvesToEmptyStringKey() {
+ LOG.info("TEST: slashAloneResolvesToEmptyStringKey");
+ final var result = JsonPointer.parse("/").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("0");
+ }
+
+ @Test
+ void tilde1DecodesSlashInKey() {
+ LOG.info("TEST: tilde1DecodesSlashInKey - /a~1b resolves key 'a/b'");
+ final var result = JsonPointer.parse("/a~1b").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("1");
+ }
+
+ @Test
+ void percentInKeyIsLiteral() {
+ LOG.info("TEST: percentInKeyIsLiteral - /c%d resolves key 'c%d'");
+ final var result = JsonPointer.parse("/c%d").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("2");
+ }
+
+ @Test
+ void caretInKeyIsLiteral() {
+ LOG.info("TEST: caretInKeyIsLiteral - /e^f resolves key 'e^f'");
+ final var result = JsonPointer.parse("/e^f").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("3");
+ }
+
+ @Test
+ void pipeInKeyIsLiteral() {
+ LOG.info("TEST: pipeInKeyIsLiteral - /g|h resolves key 'g|h'");
+ final var result = JsonPointer.parse("/g|h").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("4");
+ }
+
+ @Test
+ void backslashInKeyIsLiteral() {
+ LOG.info("TEST: backslashInKeyIsLiteral - /i\\\\j resolves key with backslash");
+ // The JSON key is "i\j" (one backslash). The pointer token is also "i\j".
+ final var result = JsonPointer.parse("/i\\j").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("5");
+ }
+
+ @Test
+ void doubleQuoteInKeyIsLiteral() {
+ LOG.info("TEST: doubleQuoteInKeyIsLiteral - /k\\\"l resolves key with quote");
+ // The JSON key is k"l. The pointer token is k"l.
+ final var result = JsonPointer.parse("/k\"l").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("6");
+ }
+
+ @Test
+ void spaceKeyIsLiteral() {
+ LOG.info("TEST: spaceKeyIsLiteral - '/ ' resolves key ' '");
+ final var result = JsonPointer.parse("/ ").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("7");
+ }
+
+ @Test
+ void tilde0DecodesTildeInKey() {
+ LOG.info("TEST: tilde0DecodesTildeInKey - /m~0n resolves key 'm~n'");
+ final var result = JsonPointer.parse("/m~0n").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("8");
+ }
+
+ // -------------------------------------------------------------------------
+ // Escape ordering — tricky token
+ // -------------------------------------------------------------------------
+
+ @Test
+ void escapeOrderingTildeOneZeroDecodesToTildeOne() {
+ LOG.info("TEST: escapeOrderingTildeOneZeroDecodesToTildeOne - ~01 decodes to ~1 not /");
+ // ~01 must decode as: first ~1→/, then ~0→~ ... wait, that is wrong.
+ // RFC says: replace ~1 first, then ~0.
+ // ~01 contains the subsequence ~0 and the digit 1.
+ // Step 1 (replace ~1): ~01 has no ~1 at position 0 (it is ~0), so no change: ~01
+ // Step 2 (replace ~0): ~0 → ~: result is ~1
+ // So ~01 decodes to the string "~1".
+ final var token = JsonPointer.decodeToken("~01", "dummy");
+ assertThat(token).isEqualTo("~1");
+ }
+
+ @Test
+ void escapeOrderingTildeOneDecodesToSlash() {
+ LOG.info("TEST: escapeOrderingTildeOneDecodesToSlash");
+ assertThat(JsonPointer.decodeToken("~1", "dummy")).isEqualTo("/");
+ }
+
+ @Test
+ void escapeOrderingTildeZeroDecodesToTilde() {
+ LOG.info("TEST: escapeOrderingTildeZeroDecodesToTilde");
+ assertThat(JsonPointer.decodeToken("~0", "dummy")).isEqualTo("~");
+ }
+
+ // -------------------------------------------------------------------------
+ // tokens() accessor
+ // -------------------------------------------------------------------------
+
+ @Test
+ void tokensForEmptyPointerIsEmpty() {
+ LOG.info("TEST: tokensForEmptyPointerIsEmpty");
+ assertThat(JsonPointer.parse("").tokens()).isEqualTo(List.of());
+ }
+
+ @Test
+ void tokensForSimplePointer() {
+ LOG.info("TEST: tokensForSimplePointer");
+ assertThat(JsonPointer.parse("/foo/bar").tokens()).isEqualTo(List.of("foo", "bar"));
+ }
+
+ @Test
+ void tokensAreDecoded() {
+ LOG.info("TEST: tokensAreDecoded");
+ assertThat(JsonPointer.parse("/a~1b/m~0n").tokens()).isEqualTo(List.of("a/b", "m~n"));
+ }
+
+ @Test
+ void tokensListIsUnmodifiable() {
+ LOG.info("TEST: tokensListIsUnmodifiable");
+ final var tokens = JsonPointer.parse("/foo").tokens();
+ assertThatThrownBy(() -> tokens.add("bar"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // toString() round-trip
+ // -------------------------------------------------------------------------
+
+ @ParameterizedTest(name = "round-trip: {0}")
+ @ValueSource(strings = {"", "/", "/foo", "/foo/0", "/a~1b", "/m~0n", "/a~1b/m~0n"})
+ void toStringRoundTrip(String pointer) {
+ LOG.info(() -> "TEST: toStringRoundTrip - " + pointer);
+ assertThat(JsonPointer.parse(pointer).toString()).isEqualTo(pointer);
+ }
+
+ // -------------------------------------------------------------------------
+ // exists()
+ // -------------------------------------------------------------------------
+
+ @Test
+ void existsTrueForValidPath() {
+ LOG.info("TEST: existsTrueForValidPath");
+ assertThat(JsonPointer.parse("/foo").exists(rfcDoc())).isTrue();
+ }
+
+ @Test
+ void existsFalseForMissingKey() {
+ LOG.info("TEST: existsFalseForMissingKey");
+ assertThat(JsonPointer.parse("/nonexistent").exists(rfcDoc())).isFalse();
+ }
+
+ @Test
+ void existsFalseForOutOfBoundsIndex() {
+ LOG.info("TEST: existsFalseForOutOfBoundsIndex");
+ assertThat(JsonPointer.parse("/foo/5").exists(rfcDoc())).isFalse();
+ }
+
+ // -------------------------------------------------------------------------
+ // Invalid pointer syntax
+ // -------------------------------------------------------------------------
+
+ @ParameterizedTest(name = "invalid syntax: \"{0}\"")
+ @ValueSource(strings = {"foo", "foo/bar", "~", "/foo/~2", "/foo/~", "/~"})
+ void invalidPointerThrowsSyntaxException(String pointer) {
+ LOG.info(() -> "TEST: invalidPointerThrowsSyntaxException - " + pointer);
+ assertThatThrownBy(() -> JsonPointer.parse(pointer))
+ .isInstanceOf(JsonPointerSyntaxException.class);
+ }
+
+ @Test
+ void syntaxExceptionCarriesPointerString() {
+ LOG.info("TEST: syntaxExceptionCarriesPointerString");
+ final var pointer = "bad";
+ assertThatThrownBy(() -> JsonPointer.parse(pointer))
+ .isInstanceOf(JsonPointerSyntaxException.class)
+ .satisfies(ex -> assertThat(((JsonPointerSyntaxException) ex).pointer())
+ .isEqualTo(pointer));
+ }
+
+ @Test
+ void nullPointerThrowsNpe() {
+ LOG.info("TEST: nullPointerThrowsNpe");
+ assertThatThrownBy(() -> JsonPointer.parse(null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // Resolution failures
+ // -------------------------------------------------------------------------
+
+ @Test
+ void missingObjectKeyThrowsResolutionException() {
+ LOG.info("TEST: missingObjectKeyThrowsResolutionException");
+ assertThatThrownBy(() -> JsonPointer.parse("/missing").resolve(rfcDoc()))
+ .isInstanceOf(JsonPointerResolutionException.class);
+ }
+
+ @Test
+ void arrayIndexOutOfBoundsThrowsResolutionException() {
+ LOG.info("TEST: arrayIndexOutOfBoundsThrowsResolutionException");
+ assertThatThrownBy(() -> JsonPointer.parse("/foo/2").resolve(rfcDoc()))
+ .isInstanceOf(JsonPointerResolutionException.class);
+ }
+
+ @Test
+ void nonNumericArrayTokenThrowsResolutionException() {
+ LOG.info("TEST: nonNumericArrayTokenThrowsResolutionException");
+ assertThatThrownBy(() -> JsonPointer.parse("/foo/bar").resolve(rfcDoc()))
+ .isInstanceOf(JsonPointerResolutionException.class);
+ }
+
+ @Test
+ void traversingPrimitiveThrowsResolutionException() {
+ LOG.info("TEST: traversingPrimitiveThrowsResolutionException");
+ // "/foo/0" resolves to the string "bar"; going one level deeper should fail.
+ assertThatThrownBy(() -> JsonPointer.parse("/foo/0/nested").resolve(rfcDoc()))
+ .isInstanceOf(JsonPointerResolutionException.class);
+ }
+
+ @Test
+ void dashTokenOnArrayFailsResolution() {
+ LOG.info("TEST: dashTokenOnArrayFailsResolution - '-' is not a valid RFC 6901 index");
+ // '-' is defined as the append sentinel in RFC 6902 only.
+ assertThatThrownBy(() -> JsonPointer.parse("/foo/-").resolve(rfcDoc()))
+ .isInstanceOf(JsonPointerResolutionException.class);
+ }
+
+ @Test
+ void nullDocumentThrowsNpe() {
+ LOG.info("TEST: nullDocumentThrowsNpe");
+ assertThatThrownBy(() -> JsonPointer.parse("/foo").resolve(null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // Array index edge cases
+ // -------------------------------------------------------------------------
+
+ @Test
+ void arrayIndexOneIsValid() {
+ LOG.info("TEST: arrayIndexOneIsValid");
+ final var result = JsonPointer.parse("/foo/1").resolve(rfcDoc());
+ assertThat(result.toString()).isEqualTo("\"baz\"");
+ }
+
+ @Test
+ void leadingZeroArrayIndexFailsResolution() {
+ LOG.info("TEST: leadingZeroArrayIndexFailsResolution - '01' is not a valid RFC 6901 index");
+ // RFC 6901 §6 forbids leading zeros for non-zero indices.
+ assertThatThrownBy(() -> JsonPointer.parse("/foo/01").resolve(rfcDoc()))
+ .isInstanceOf(JsonPointerResolutionException.class);
+ }
+
+ @Test
+ void negativeArrayIndexFailsResolution() {
+ LOG.info("TEST: negativeArrayIndexFailsResolution");
+ assertThatThrownBy(() -> JsonPointer.parse("/foo/-1").resolve(rfcDoc()))
+ .isInstanceOf(JsonPointerResolutionException.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // JSON null value is resolvable
+ // -------------------------------------------------------------------------
+
+ @Test
+ void nullValueInDocumentIsResolvable() {
+ LOG.info("TEST: nullValueInDocumentIsResolvable");
+ final var doc = Json.parse("{\"key\": null}");
+ final var result = JsonPointer.parse("/key").resolve(doc);
+ assertThat(result).isInstanceOf(JsonNull.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // Nested document
+ // -------------------------------------------------------------------------
+
+ @ParameterizedTest(name = "nested resolve: {0} -> {1}")
+ @CsvSource({
+ "/a, 1",
+ "/b/c, 2",
+ "/b/d/0, 3",
+ "/b/d/1, 4"
+ })
+ void nestedDocumentResolution(String pointer, String expectedJson) {
+ LOG.info(() -> "TEST: nestedDocumentResolution - " + pointer + " -> " + expectedJson);
+ final var doc = Json.parse("{\"a\":1,\"b\":{\"c\":2,\"d\":[3,4]}}");
+ final var result = JsonPointer.parse(pointer.trim()).resolve(doc);
+ assertThat(result.toString()).isEqualTo(expectedJson.trim());
+ }
+}
diff --git a/pom.xml b/pom.xml
index 6f983d9..8c90d80 100644
--- a/pom.xml
+++ b/pom.xml
@@ -42,6 +42,7 @@
json-compatibility-suite
json-java21-jtd
json-java21-jsonpath
+ json-java21-jsonpointer