diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonArray.java b/src/java.base/share/classes/jdk/internal/util/json/JsonArray.java new file mode 100644 index 0000000000000..e1418cafc16dc --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonArray.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +/** + * The interface that represents JSON array + */ +public sealed interface JsonArray extends JsonValue permits JsonArrayImpl { + /** + * {@return the list of {@code JsonValue} elements in this array + * value} + */ + List values(); + + /** + * {@return the stream of {@code JsonValue} elements in this JSON array} + */ + Stream stream(); + + /** + * {@return the {@code JsonValue} element in this JSON array} + * @param index the index of the element + */ + JsonValue get(int index); + + /** + * {@return the list of {@code Object}s in this array} + */ + List toUntyped(); + + /** + * {@return the size of this JSON array}. + */ + int size(); + + /** + * {@return the {@code JsonArray} created from the given + * list of {@code Object}s} + * + * @param from the list of {@code Object}s. Non-null. + * @throws StackOverflowError if {@code from} contains a circular reference + */ + static JsonArray fromUntyped(List from) { + Objects.requireNonNull(from); + return new JsonArrayImpl(from); + } + + /** + * {@return the {@code JsonArray} created from the given + * varargs of {@code JsonValue}s} + * + * @param values the varargs of {@code JsonValue}s. Non-null. + */ + @SafeVarargs + static JsonArray ofValues(T... values) { + Objects.requireNonNull(values); + return new JsonArrayImpl(values); + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonArrayImpl.java b/src/java.base/share/classes/jdk/internal/util/json/JsonArrayImpl.java new file mode 100644 index 0000000000000..2d4d26842391f --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonArrayImpl.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +/** + * JsonArray implementation class + */ +final class JsonArrayImpl implements JsonArray, JsonValueImpl { + private final JsonDocumentInfo docInfo; + private final int startOffset, endOffset; // exclusive + private final int endIndex; + private List theValues; + // For lazy inflation + private int currIndex; + private boolean inflated; + + JsonArrayImpl(List from) { + docInfo = null; + startOffset = 0; + endOffset = 0; + endIndex = 0; + inflated = true; + List l = new ArrayList<>(from.size()); + for (Object o : from) { + l.add(JsonValue.fromUntyped(o)); + } + theValues = Collections.unmodifiableList(l); + } + + JsonArrayImpl(JsonValue... values) { + docInfo = null; + startOffset = 0; + endOffset = 0; + endIndex = 0; + inflated = true; + theValues = Arrays.asList(values); + } + + JsonArrayImpl(JsonDocumentInfo docInfo, int offset, int index) { + this.docInfo = docInfo; + startOffset = offset; + currIndex = index; + endIndex = docInfo.getStructureLength(index, offset, '[', ']'); + endOffset = docInfo.getOffset(endIndex) + 1; + } + + @Override + public List values() { + if (!inflated) { + inflateAll(); + } + return theValues; + } + + @Override + public Stream stream() { + return values().stream(); + } + + @Override + public JsonValue get(int index) { + JsonValue val; + if (theValues == null) { + val = inflateUntilMatch(index); + } else { + // Search for key in list first, otherwise offsets + if (theValues.size() - 1 >= index) { + val = theValues.get(index); + } else if (inflated) { + throw new IndexOutOfBoundsException( + String.format("Index %s is out of bounds for length %s", index, theValues.size())); + } + else { + val = inflateUntilMatch(index); + } + } + return val; + } + + @Override + public int getEndOffset() { + return endOffset; + } + + @Override + public int getEndIndex() { + return endIndex; + } + + @Override + public boolean equals(Object o) { + return this == o || + o instanceof JsonArrayImpl ojai && + Objects.equals(values(), ojai.values()); + } + + @Override + public int hashCode() { + return Objects.hash(values()); + } + + // Inflate the entire list + private void inflateAll() { + inflate(-1); + } + + // Inflate until the index is created in the array + // If no match, should throw IOOBE + private JsonValue inflateUntilMatch(int index) { + var val = inflate(index); + // null returned on no match, fail + if (val == null) { + throw new IndexOutOfBoundsException( + String.format("Index %s is out of bounds for length %s", index, theValues.size())); + } + return val; + } + + // Used for eager or lazy inflation + private JsonValue inflate(int searchIndex) { + if (inflated) { // prevent misuse + throw new InternalError("JsonArray is already inflated"); + } + + if (theValues == null) { // first time init + if (JsonParser.checkWhitespaces(docInfo, startOffset + 1, endOffset - 1)) { + theValues = Collections.emptyList(); + inflated = true; + return null; + } + theValues = new ArrayList<>(); + } + + var v = theValues; + while (currIndex < endIndex) { + // Traversal starts on the opening bracket, or a comma + int offset = docInfo.getOffset(currIndex) + 1; + boolean shouldWalk = false; + + // For obj/arr we need to walk the comma to get the correct starting index + if (docInfo.isWalkableStartIndex(docInfo.charAtIndex(currIndex + 1))) { + shouldWalk = true; + currIndex++; + } + + var value = JsonParser.parseValue(docInfo, offset, currIndex); + v.add(value); + + offset = ((JsonValueImpl)value).getEndOffset(); + currIndex = ((JsonValueImpl)value).getEndIndex(); + + if (shouldWalk) { + currIndex++; + } + + // Check that there is only a single valid JsonValue + // Between the end of the value and the next index, there should only be WS + if (!JsonParser.checkWhitespaces(docInfo, offset, docInfo.getOffset(currIndex))) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Unexpected character(s) found after JsonValue: %s." + .formatted(value), offset), offset); + } + var c = docInfo.charAtIndex(currIndex); + if (c == ',' || c == ']') { + if (searchIndex == theValues.size() - 1) { + if (c == ']') { + inflated = true; + theValues = Collections.unmodifiableList(v); + } + return value; + } + if (c == ']') { + break; + } + } else { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Unexpected character(s) found after JsonValue: %s." + .formatted(value), offset), offset); + } + } + // inflated, so make unmodifiable + inflated = true; + theValues = Collections.unmodifiableList(v); + return null; + } + + @Override + public List toUntyped() { + return values().stream() + .map(JsonValue::toUntyped) + .toList(); + } + + @Override + public String toString() { + return formatCompact(); + } + + @Override + public String formatCompact() { + var s = new StringBuilder("["); + for (JsonValue v: values()) { + s.append(v.toString()).append(","); + } + if (!values().isEmpty()) { + s.setLength(s.length() - 1); // trim final comma + } + return s.append("]").toString(); + } + + @Override + public String formatReadable() { + return formatReadable(0, false); + } + + @Override + public String formatReadable(int indent, boolean isField) { + var prefix = " ".repeat(indent); + var s = new StringBuilder(isField ? " " : prefix); + if (values().isEmpty()) { + s.append("[]"); + } else { + s.append("[\n"); + for (JsonValue v: values()) { + if (v instanceof JsonValueImpl impl) { + s.append(impl.formatReadable(indent + INDENT, false)).append(",\n"); + } else { + throw new InternalError("type mismatch"); + } + } + s.setLength(s.length() - 2); // trim final comma/newline + s.append("\n").append(prefix).append("]"); + } + return s.toString(); + } + + @Override + public int size() { + return values().size(); + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonBoolean.java b/src/java.base/share/classes/jdk/internal/util/json/JsonBoolean.java new file mode 100644 index 0000000000000..2c2a34c4a9448 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonBoolean.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Objects; + +/** + * The interface that represents JSON boolean + */ +public sealed interface JsonBoolean extends JsonValue permits JsonBooleanImpl { + /** + * {@return the {@code boolean} value represented with this + * {@code JsonBoolean} value} + */ + boolean value(); + + /** + * {@return the {@code Boolean} value represented with this + * {@code JsonBoolean} value} + */ + Boolean toUntyped(); + + /** + * {@return the {@code JsonBoolean} created from the given + * {@code Boolean} object} + * + * @param from the given {@code Boolean}. Non-null. + */ + static JsonBoolean fromBoolean(Boolean from) { + Objects.requireNonNull(from); + return from ? JsonBooleanImpl.TRUE : JsonBooleanImpl.FALSE; + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonBooleanImpl.java b/src/java.base/share/classes/jdk/internal/util/json/JsonBooleanImpl.java new file mode 100644 index 0000000000000..d356f4d652569 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonBooleanImpl.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Objects; + +/** + * JsonBoolean implementation class + */ +final class JsonBooleanImpl implements JsonBoolean, JsonValueImpl { + private final JsonDocumentInfo docInfo; + private final int startOffset, endOffset; + private final int endIndex; + private Boolean theBoolean; + + static final JsonBooleanImpl TRUE = new JsonBooleanImpl(true); + static final JsonBooleanImpl FALSE = new JsonBooleanImpl(false); + + JsonBooleanImpl(Boolean bool) { + docInfo = null; + startOffset = 0; + endOffset = 0; + endIndex = 0; + theBoolean = bool; + } + + JsonBooleanImpl(JsonDocumentInfo docInfo, int offset, int index) { + this.docInfo = docInfo; + startOffset = offset; + endIndex = docInfo.nextIndex(index); + endOffset = endIndex != -1 ? docInfo.getOffset(endIndex) : docInfo.getEndOffset(); + } + + @Override + public boolean value() { + if (theBoolean == null) { + var strVal = docInfo.substring(startOffset, endOffset).trim(); + theBoolean = switch (strVal) { + case "true", "false" -> Boolean.parseBoolean(strVal); + default -> throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Not a boolean.", startOffset), startOffset); + }; + } + return theBoolean; + } + + @Override + public int getEndOffset() { + return endOffset; + } + + @Override + public int getEndIndex() { + return endIndex; + } + + @Override + public boolean equals(Object o) { + return this == o || + o instanceof JsonBooleanImpl ojbi && + Objects.equals(value(), ojbi.value()); + } + + @Override + public int hashCode() { + return Objects.hash(value()); + } + + @Override + public Boolean toUntyped() { + return value(); + } + + @Override + public String toString() { + return formatCompact(); + } + + @Override + public String formatCompact() { + return Boolean.valueOf(value()).toString(); + } + + @Override + public String formatReadable() { + return formatReadable(0, false); + } + + @Override + public String formatReadable(int indent, boolean isField) { + return " ".repeat(isField ? 1 : indent) + toString(); + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonDocumentInfo.java b/src/java.base/share/classes/jdk/internal/util/json/JsonDocumentInfo.java new file mode 100644 index 0000000000000..168467530abc8 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonDocumentInfo.java @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Objects; + +final class JsonDocumentInfo { + private final RawDocument doc; + private final int[] tokenOffsets; + private final int indexCount; + private final int endOffset; + + JsonDocumentInfo(String in) { + doc = new RawDocument(in); + endOffset = in.length(); + tokenOffsets = new int[endOffset]; + indexCount = createOffsetsArray(); + } + + JsonDocumentInfo(char[] in) { + doc = new RawDocument(in); + endOffset = doc.length(); + tokenOffsets = new int[endOffset]; + indexCount = createOffsetsArray(); + } + + // gets offset in the input from the array index + int getOffset(int index) { + Objects.checkIndex(index, indexCount); + return tokenOffsets[index]; + } + + int getEndOffset() { + return endOffset; + } + + int getIndexCount() { + return indexCount; + } + + // gets the char at the specified offset in the input + char charAt(int offset) { + return doc.charAt(offset); + } + + // Used by Json String, Boolean, Null, and Number to get the endIndex + // Returns -1, if the next index is not within bounds of indexCount + int nextIndex(int index) { + if (index + 1 < indexCount) { + return index + 1; + } else { + return -1; + } + } + + // Used by JsonObject and JsonArray to get the endIndex + int getStructureLength(int startIdx, int startOff, char startToken, char endToken) { + var index = startIdx + 1; + int depth = 0; + while (index < indexCount) { + var c = charAtIndex(index); + if (c == startToken) { + depth++; + } else if (c == endToken) { + depth--; + } + + if (depth < 0) { + break; + } + + index++; + } + + if (index >= indexCount) { + throw new JsonParseException(composeParseExceptionMessage( + "Braces or brackets do not match.", startOff), startOff); + } + + return index; + } + + // for convenience + char charAtIndex(int index) { + return doc.charAt(getOffset(index)); + } + + // Convenience to skip an index when inflating a JsonObject/Array + boolean isWalkableStartIndex(char c) { + return switch (c) { + // Order is important, with String being most common JsonType + case '"', '{', '[' -> true; + default -> false; + }; + } + + // gets the substring at the specified start/end offsets in the input + String substring(int startOffset, int endOffset) { + return doc.substring(startOffset, endOffset); + } + + // gets the substring at the specified start/end offsets in the input with decoding + // escape sequences + String unescape(int startOffset, int endOffset) { + var sb = new StringBuilder(); + var escape = false; + for (int offset = startOffset; offset < endOffset; offset++) { + var c = doc.charAt(offset); + + if (escape) { + switch (c) { + case '"', '\\', '/' -> {} + case 'b' -> c = '\b'; + case 'f' -> c = '\f'; + case 'n' -> c = '\n'; + case 'r' -> c = '\r'; + case 't' -> c = '\t'; + case 'u' -> { + if (offset + 4 < endOffset) { + c = codeUnit(offset + 1); + offset += 4; + } else { + throw new JsonParseException(composeParseExceptionMessage( + "Illegal Unicode escape.", offset), offset); + } + } + default -> throw new JsonParseException(composeParseExceptionMessage( + "Illegal escape.", offset), offset); + } + escape = false; + } else if (c == '\\') { + escape = true; + continue; + } else if (c < ' ') { + throw new JsonParseException(composeParseExceptionMessage( + "Unescaped control code.", offset), offset); + } + + sb.append(c); + } + + return sb.toString(); + } + + // Utility method to compose parse exception messages that include offsets/chars + String composeParseExceptionMessage(String message, int offset) { + return message + " Offset: %d (%s)" + .formatted(offset, substring(offset, Math.min(offset + 8, endOffset))); + } + + // Utility method to compose parse exception messages that include offsets/chars + String composeParseExceptionMessage2(String message, int offset) { + return message + " Offset: %d (%s)" + .formatted(offset, substring(offset, Math.min(offset + 8, endOffset))); + } + + private char codeUnit(int offset) { + char val = 0; + for (int index = 0; index < 4; index ++) { + char c = doc.charAt(offset + index); + val <<= 4; + val += (char) ( + switch (c) { + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> c - '0'; + case 'a', 'b', 'c', 'd', 'e', 'f' -> c - 'a' + 10; + case 'A', 'B', 'C', 'D', 'E', 'F' -> c - 'A' + 10; + default -> throw new JsonParseException(composeParseExceptionMessage( + "Invalid Unicode escape.", offset), offset); + } ); + } + return val; + } + + private int createOffsetsArray() { + int index = 0; + boolean inQuote = false; + + for (int offset = 0; offset < doc.length(); offset++) { + char c = doc.charAt(offset); + switch (c) { + case '{', '}', '[', ']', '"', ':', ',' -> { + if (c == '"') { + if (inQuote) { + // check prepending backslash + int lookback = offset - 1; + while (lookback >= 0 && doc.charAt(lookback) == '\\') { + lookback --; + } + if ((offset - lookback) % 2 != 0) { + inQuote = false; + tokenOffsets[index++] = offset; + } + } else { + tokenOffsets[index++] = offset; + inQuote = true; + } + } else { + if (!inQuote) { + tokenOffsets[index++] = offset; + } + } + } + } + } + + return index; + } + + /** + * encapsulates the access to the document underneath, either + * a String or a char array. + */ + private static class RawDocument { + final String inStr; + final char[] inChArray; + + RawDocument(String in) { + inStr = in; + inChArray = null; + } + + RawDocument(char[] in) { + inStr = null; + inChArray = in; + } + + int length() { + if (inStr != null) { + return inStr.length(); + } else { + assert inChArray != null; + return inChArray.length; + } + } + + char charAt(int index) { + if (inStr != null) { + return inStr.charAt(index); + } else { + assert inChArray != null; + return inChArray[index]; + } + } + + String substring(int start, int end) { + if (inStr != null) { + return inStr.substring(start, end); + } else { + assert inChArray != null; + return new String(inChArray, start, end - start); + } + } + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonNull.java b/src/java.base/share/classes/jdk/internal/util/json/JsonNull.java new file mode 100644 index 0000000000000..5b81fbe45c892 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonNull.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +/** + * The interface that represents JSON null + */ +public sealed interface JsonNull extends JsonValue permits JsonNullImpl { + /** + * {@return {@code null}} + */ + Object toUntyped(); + + /** + * {@return the {@code JsonNull} that represents "null" JSON value} + */ + static JsonNull ofNull() { + return JsonNullImpl.NULL; + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonNullImpl.java b/src/java.base/share/classes/jdk/internal/util/json/JsonNullImpl.java new file mode 100644 index 0000000000000..e13662b0e09a7 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonNullImpl.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Objects; + +/** + * JsonNull implementation class + */ +final class JsonNullImpl implements JsonNull, JsonValueImpl { + final JsonDocumentInfo docInfo; + final int startOffset, endOffset; + private final int endIndex; + + static final JsonNullImpl NULL = new JsonNullImpl(); + static final String VALUE = "null"; + static final int HASH = Objects.hash(VALUE); + + JsonNullImpl() { + docInfo = null; + startOffset = 0; + endOffset = 0; + endIndex = 0; + } + + JsonNullImpl(JsonDocumentInfo docInfo, int offset, int index) { + this.docInfo = docInfo; + startOffset = offset; + endIndex = docInfo.nextIndex(index); + endOffset = endIndex != -1 ? docInfo.getOffset(endIndex) : docInfo.getEndOffset(); + if (!"null".equals(docInfo.substring(startOffset, endOffset).trim())) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "'null' expected.", startOffset), startOffset); + } + } + + @Override + public int getEndOffset() { + return endOffset; + } + + @Override + public int getEndIndex() { + return endIndex; + } + + @Override + public boolean equals(Object o) { + return this == o || o instanceof JsonNullImpl; + } + + @Override + public int hashCode() { + return HASH; + } + + @Override + public Object toUntyped() { + return null; + } + + @Override + public String toString() { + return VALUE; + } + + @Override + public String formatCompact() { + return VALUE; + } + + @Override + public String formatReadable() { + return formatReadable(0, false); + } + + @Override + public String formatReadable(int indent, boolean isField) { + return " ".repeat(isField ? 1 : indent) + VALUE; + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonNumber.java b/src/java.base/share/classes/jdk/internal/util/json/JsonNumber.java new file mode 100644 index 0000000000000..37ca352986ac4 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonNumber.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Objects; + +/** + * The interface that represents JSON number + */ +public sealed interface JsonNumber extends JsonValue permits JsonNumberImpl { + /** + * {@return the {@code Number} value represented with this + * {@code JsonNumber} value} + */ + Number value(); + + /** + * {@return the {@code Number} value represented with this + * {@code JsonNumber} value}. The actual Number type depends + * on the number value in this JsonNumber object. + */ + Number toUntyped(); + + /** + * {@return the {@code JsonNumber} created from the given + * {@code Number} object} + * + * @param num the given {@code Number}. Non-null. + */ + static JsonNumber fromNumber(Number num) { + Objects.requireNonNull(num); + return new JsonNumberImpl(num); + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonNumberImpl.java b/src/java.base/share/classes/jdk/internal/util/json/JsonNumberImpl.java new file mode 100644 index 0000000000000..c9d7fd47a173d --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonNumberImpl.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.math.BigInteger; +import java.util.Objects; + +/** + * JsonNumber implementation class + */ +final class JsonNumberImpl implements JsonNumber, JsonValueImpl { + private final JsonDocumentInfo docInfo; + private final int startOffset, endOffset; + private final int endIndex; + private Number theNumber; + private String numString; + + JsonNumberImpl(Number num) { + docInfo = null; + startOffset = 0; + endOffset = 0; + endIndex = 0; + theNumber = num; + } + + JsonNumberImpl(JsonDocumentInfo docInfo, int offset, int index) { + this.docInfo = docInfo; + startOffset = offset; + endIndex = docInfo.nextIndex(index); + endOffset = endIndex != -1 ? docInfo.getOffset(endIndex) : docInfo.getEndOffset(); + } + + @Override + public Number value() { + if (theNumber == null) { + theNumber = parseNumber(); + } + return theNumber; + } + + @Override + public int getEndOffset() { + return endOffset; + } + + @Override + public int getEndIndex() { + return endIndex; + } + + @Override + public boolean equals(Object o) { + return this == o || + o instanceof JsonNumberImpl ojni && + Objects.equals(toString(), ojni.toString()); + } + + @Override + public int hashCode() { + return Objects.hash(toString()); + } + + private Number parseNumber() { + // check syntax + boolean sawDecimal = false; + boolean sawExponent = false; + boolean sawZero = false; + boolean sawWhitespace = false; + boolean havePart = false; + + int start = JsonParser.skipWhitespaces(docInfo, startOffset); + int offset = start; + for (; offset < endOffset && !sawWhitespace; offset++) { + switch (docInfo.charAt(offset)) { + case '-' -> { + if (offset != start && !sawExponent) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Minus sign in the middle.", offset), offset); + } + } + case '+' -> { + if (!sawExponent || havePart) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Plus sign appears in a wrong place.", offset), offset); + } + } + case '0' -> { + if (!havePart) { + sawZero = true; + } + havePart = true; + } + case '1', '2', '3', '4', '5', '6', '7', '8', '9' -> { + if (!sawDecimal && !sawExponent && sawZero) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Zero not allowed here.", offset), offset); + } + havePart = true; + } + case '.' -> { + if (sawDecimal) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "More than one decimal point.", offset), offset); + } else { + if (!havePart) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "No integer part.", offset), offset); + } + sawDecimal = true; + havePart = false; + } + } + case 'e', 'E' -> { + if (sawExponent) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "More than one exponent symbol.", offset), offset); + } else { + if (!havePart) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "No integer or fraction part.", offset), offset); + } + sawExponent = true; + havePart = false; + } + } + case ' ', '\t', '\r', '\n' -> { + sawWhitespace = true; + offset --; + } + default -> throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Number not recognized.", offset), offset); + } + } + + if (!JsonParser.checkWhitespaces(docInfo, offset, endOffset)) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Garbage after the number.", offset), offset); + } + if (!havePart) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Dangling decimal point or exponent symbol.", offset), offset); + } + + numString = docInfo.substring(start, offset); + if (sawDecimal || sawExponent) { + var num = Double.parseDouble(numString); + + if (num == Double.POSITIVE_INFINITY || + num == Double.NEGATIVE_INFINITY) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Number too large or small.", offset), offset); + } + + return num; + } else { + // integral numbers + try { + return Integer.parseInt(numString); + } catch (NumberFormatException _) { + // int overflow. try long + try { + return Long.parseLong(numString); + } catch (NumberFormatException _) { + // long overflow. convert to BigInteger + return new BigInteger(numString); + } + } + } + } + + @Override + public Number toUntyped() { + return value(); + } + + @Override + public String toString() { + return formatCompact(); + } + + @Override + public String formatCompact() { + if (numString != null) { + return numString; + } else if (theNumber != null) { + return theNumber.toString(); // use theNumber if we have it + } else { + value(); // make sure parse is done + return numString; + } + } + + @Override + public String formatReadable() { + return formatReadable(0, false); + } + + @Override + public String formatReadable(int indent, boolean isField) { + return " ".repeat(isField ? 1 : indent) + toString(); + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonObject.java b/src/java.base/share/classes/jdk/internal/util/json/JsonObject.java new file mode 100644 index 0000000000000..ce9866d1b9850 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonObject.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * The interface that represents JSON object + */ +public sealed interface JsonObject extends JsonValue permits JsonObjectImpl { + /** + * {@return the map of {@code String} to {@code JsonValue} members in this + * JSON object} + */ + Map keys(); + + /** + * {@return the {@code JsonValue} member in this JSON object} + * @param key the String key + */ + JsonValue get(String key); + + /** + * {@return the {@code JsonValue} member, or {@code defaultValue} if this + * JSON object does not contain the key} + * @param key the String key + */ + JsonValue getOrDefault(String key, JsonValue defaultValue); + + /** + * {@return {@code true} if this JSON object contains a mapping for + * the specified key} + * @param key the String key + */ + boolean contains(String key); + + /** + * {@return the map of {@code String} to {@code Object} in this + * JSON object} + */ + Map toUntyped(); + + /** + * {@return the size of this JSON object} + */ + int size(); + + /** + * {@return the {@code JsonObject} created from the given + * Map of {@code Object}s} + * + * @param from the Map of {@code Object}s. Non-null. + * @throws StackOverflowError if {@code from} contains a circular reference + */ + static JsonObject fromUntyped(Map from) { + Objects.requireNonNull(from); + return new JsonObjectImpl(from); + } + + /** + * Used to build instances of {@code JsonObject} + * + * @apiNote Use this class to construct a new {@code JsonObject} from + * an existing {@code JsonObject}. + */ + final class Builder { + + // A mutable form of 'theKeys' to be used by the Builder + private final Map map; + + /** + * Constructs a {@code Builder} composed of the {@code JsonObject} provided. + * + * @param obj the {@code JsonObject} to initialize the {@code Builder} with. + * @throws NullPointerException if {@code JsonObject} is null. + */ + public Builder(JsonObject obj) { + Objects.requireNonNull(obj); + var impl = (JsonObjectImpl) obj; + if (!impl.inflated) { + // Finish inflation to get all elements if not fully inflated + impl.inflateAll(); + } + // This is safe as JsonValue is final and immutable. + // 'theKeys' is also unmodifiable once fully inflated + this.map = new HashMap<>(impl.theKeys); + } + + /** + * Constructs an empty {@code Builder}. + */ + public Builder() { + this.map = new HashMap<>(); + } + + /** + * Associates the specified value with the specified key in this + * {@code Builder}. If the {@code Builder} previously contained a mapping + * for the key, the old value is replaced. + * + * @return This {@code Builder}. + */ + public Builder put(String key, JsonValue val) { + this.map.put(key, val); + return this; + } + + /** + * Removes the mapping for the specified key from this {@code Builder}, + * if present. + * + * @return This {@code Builder}. + */ + public Builder remove(String key) { + this.map.remove(key); + return this; + } + + /** + * Resets the {@code Builder} to its initial, empty state. + * + * @return This {@code Builder}. + */ + public Builder clear() { + this.map.clear(); + return this; + } + + /** + * Returns an instance of {@code JsonObject} obtained from the + * operations performed on this {@code Builder}. + * + * @return A {@code JsonObject}. + */ + public JsonObject build() { + return JsonObject.fromUntyped(map); + } + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonObjectImpl.java b/src/java.base/share/classes/jdk/internal/util/json/JsonObjectImpl.java new file mode 100644 index 0000000000000..ad4b0284ea691 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonObjectImpl.java @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * JsonObject implementation class + */ +final class JsonObjectImpl implements JsonObject, JsonValueImpl { + private final JsonDocumentInfo docInfo; + private final int startOffset, endOffset; + private final int endIndex; + Map theKeys; + // For lazy inflation + private int currIndex; + boolean inflated; + + JsonObjectImpl(Map map) { + docInfo = null; + startOffset = 0; + endOffset = 0; + endIndex = 0; + inflated = true; + HashMap m = HashMap.newHashMap(map.size()); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String strKey)) { + throw new IllegalStateException("Key is not a String: " + entry.getKey()); + } else { + if (entry.getValue() instanceof JsonValue jVal) { + m.put(strKey, jVal); + } else { + m.put(strKey, JsonValue.fromUntyped(entry.getValue())); + } + } + } + theKeys = Collections.unmodifiableMap(m); + } + + JsonObjectImpl(JsonDocumentInfo docInfo, int offset, int index) { + this.docInfo = docInfo; + startOffset = offset; + currIndex = index; + endIndex = docInfo.getStructureLength(index, offset, '{', '}'); + endOffset = docInfo.getOffset(endIndex) + 1; + } + + @Override + public Map keys() { + if (!inflated) { + inflateAll(); + } + return theKeys; + } + + @Override + public JsonValue get(String key) { + JsonValue val; + if (theKeys == null) { + val = inflateUntilMatch(key); + } else { + // Search for key in hashmap first, otherwise offsets + val = theKeys.get(key); + if (val == null) { + if (inflated) { + return null; + } else { + val = inflateUntilMatch(key); + } + } + } + return val; + } + + @Override + public JsonValue getOrDefault(String key, JsonValue defaultValue) { + var val = get(key); + if (val == null) { + val = defaultValue; + } + return val; + } + + @Override + public boolean contains(String key) { + // See if we already have it, otherwise continue inflation to check + JsonValue val; + if (theKeys == null) { + val = inflateUntilMatch(key); + } else { + if (!theKeys.containsKey(key)) { + if (inflated) { + val = null; + } else { + val = inflateUntilMatch(key); + } + } else { + return true; + } + } + return val != null; + } + + @Override + public int getEndOffset() { + return endOffset; + } + + @Override + public int getEndIndex() { + return endIndex; + } + + @Override + public boolean equals(Object o) { + return this == o || + o instanceof JsonObjectImpl ojoi && + Objects.equals(keys(), ojoi.keys()); + } + + @Override + public int hashCode() { + return Objects.hash(keys()); + } + + // Inflate the entire map + void inflateAll() { + inflate(null); + } + + // Upon match, return the key and defer the rest of inflation + // Otherwise, if no match, returns null + private JsonValue inflateUntilMatch(String key) { + return inflate(key); + } + + // Used for eager or lazy inflation + private JsonValue inflate(String searchKey) { + if (inflated) { // prevent misuse + throw new InternalError("JsonObject is already inflated"); + } + + if (theKeys == null) { // first time init + if (JsonParser.checkWhitespaces(docInfo, startOffset + 1, endOffset - 1)) { + theKeys = Collections.emptyMap(); + inflated = true; + return null; + } + theKeys = new HashMap<>(); + } + + var k = theKeys; + while (currIndex < endIndex) { + // Traversal starts on the opening bracket, or a comma + // We need to parse a key, a value and ensure that there + // is no added garbage within our key/value pair + // As the initial creation was done lazily, we validate now + int offset; + var keyOffset = docInfo.getOffset(currIndex + 1); + + // Check the key indices are as expected + if (docInfo.charAtIndex(currIndex + 1) != '"' || + docInfo.charAtIndex(currIndex + 2) != '"' || + docInfo.charAtIndex(currIndex + 3) != ':') { + offset = keyOffset; + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Invalid key:value syntax.", offset), offset); + } + + // Ensure no garbage before key + if (!JsonParser.checkWhitespaces(docInfo, + docInfo.getOffset(currIndex)+1, docInfo.getOffset(currIndex+1))) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Unexpected character(s) found instead of key.", + docInfo.getOffset(currIndex)+1), docInfo.getOffset(currIndex)+1); + } + + var key = docInfo.unescape(keyOffset + 1, + docInfo.getOffset(currIndex + 2)); + + // Ensure no garbage after key and before colon + if (!JsonParser.checkWhitespaces(docInfo, + docInfo.getOffset(currIndex+2)+1, docInfo.getOffset(currIndex+3))) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Unexpected character(s) found after key: \"%s\".".formatted(key), + docInfo.getOffset(currIndex+2)+1), docInfo.getOffset(currIndex+2)+1); + } + + // Check for duplicate keys + if (k.containsKey(key)) { + offset = keyOffset; + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Duplicate keys not allowed.", offset), offset); + } + + boolean shouldWalk = false; + offset = docInfo.getOffset(currIndex + 3) + 1; + if (docInfo.isWalkableStartIndex(docInfo.charAtIndex(currIndex + 4))) { + shouldWalk = true; + currIndex = currIndex + 4; + } else { + currIndex = currIndex + 3; + } + + var value = JsonParser.parseValue(docInfo, offset, currIndex); + k.put(key, value); + + offset = ((JsonValueImpl)value).getEndOffset(); + currIndex = ((JsonValueImpl)value).getEndIndex(); + + if (shouldWalk) { + currIndex++; + } + + // Check there is no garbage after the JsonValue + if (!JsonParser.checkWhitespaces(docInfo, offset, docInfo.getOffset(currIndex))) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Unexpected character(s) found after JsonValue: %s, for key: \"%s\"." + .formatted(value, key), offset), offset); + } + + + var c = docInfo.charAtIndex(currIndex); + if (c == ',' || c == '}') { + if (searchKey != null && searchKey.equals(key)) { + if (c == '}') { + inflated = true; + theKeys = Collections.unmodifiableMap(k); + } + return value; + } + if (c == '}') { + break; + } + } else { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Unexpected character(s) found after JsonValue: %s, for key: \"%s\"." + .formatted(value, key), offset), offset); + } + } + // inflated, so make unmodifiable + inflated = true; + theKeys = Collections.unmodifiableMap(k); + return null; + } + + @Override + public Map toUntyped() { + return keys().entrySet().stream() + .collect(HashMap::new, // to allow `null` value + (m, e) -> m.put(e.getKey(), e.getValue().toUntyped()), + HashMap::putAll); + } + + @Override + public String toString() { + return formatCompact(); + } + + @Override + public String formatCompact() { + var s = new StringBuilder("{"); + for (Map.Entry kv: keys().entrySet()) { + s.append("\"").append(kv.getKey()).append("\":") + .append(kv.getValue().toString()) + .append(","); + } + if (!keys().isEmpty()) { + s.setLength(s.length() - 1); // trim final comma + } + return s.append("}").toString(); + } + + @Override + public String formatReadable() { + return formatReadable(0, false); + } + + @Override + public String formatReadable(int indent, boolean isField) { + var prefix = " ".repeat(indent); + var s = new StringBuilder(isField ? " " : prefix); + if (keys().isEmpty()) { + s.append("{}"); + } else { + s.append("{\n"); + keys().entrySet().stream() + .sorted(Map.Entry.comparingByKey(String::compareTo)) + .forEach(e -> { + var key = e.getKey(); + var value = e.getValue(); + if (value instanceof JsonValueImpl val) { + s.append(prefix) + .append(" ".repeat(INDENT)) + .append("\"") + .append(key) + .append("\":") + .append(val.formatReadable(indent + INDENT, true)) + .append(",\n"); + } else { + throw new IllegalStateException("type mismatch"); + } + }); + s.setLength(s.length() - 2); // trim final comma + s.append("\n").append(prefix).append("}"); + } + return s.toString(); + } + + @Override + public int size() { + return keys().size(); + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonParseException.java b/src/java.base/share/classes/jdk/internal/util/json/JsonParseException.java new file mode 100644 index 0000000000000..c3a95d7dc7b00 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonParseException.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.io.Serial; + +/** + * Signals that an error has been detected while parsing the + * JSON document. + */ +public class JsonParseException extends RuntimeException { + @Serial + private static final long serialVersionUID = 7022545379651073390L; + private final int errorPosition; + + /** + * Constructs a JsonParseException with the specified detail message. + * @param message the detail message + * @param errorPosition the offset of the error on parsing the document + */ + public JsonParseException(String message, int errorPosition) { + super(message); + this.errorPosition = errorPosition; + } + + /** + * {@return the offset of the error on parsing the document} + */ + public int getErrorPosition() { + return errorPosition; + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonParser.java b/src/java.base/share/classes/jdk/internal/util/json/JsonParser.java new file mode 100644 index 0000000000000..b9667b3f290f7 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonParser.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Objects; + +/** + * A simple JSON parser that utilizes the deconstructor pattern matching. For a + * simple JSON data, values can be retrieved using the pattern match, such as: + * {@snippet lang=java : + * JsonValue doc = JsonParser.parse(aString); + * if (doc instanceof JsonObject(var keys) && + * keys.get("name") instanceof JsonString(var name) && + * keys.get("age") instanceof JsonNumber(var age)) { ... } + * } + */ +public class JsonParser { + + /** + * Parses and creates the top level {@code JsonValue} in this JSON + * document. + * + * @param in the input JSON document as {@code String}. Non-null. + * @return the top level {@code JsonValue} + */ + public static JsonValue parse(String in) { + Objects.requireNonNull(in); + return parseImpl(new JsonDocumentInfo(in)); + } + + /** + * Parses and creates the top level {@code JsonValue} in this JSON + * document. + * + * @param in the input JSON document as {@code char[]}. Non-null. + * @return the top level {@code JsonValue} + */ + public static JsonValue parse(char[] in) { + Objects.requireNonNull(in); + return parseImpl(new JsonDocumentInfo(in)); + } + + // return the root value + private static JsonValue parseImpl(JsonDocumentInfo docInfo) { + JsonValue jv = parseValue(docInfo, 0, 0); + + // check the remainder is whitespace + var offset = ((JsonValueImpl)jv).getEndOffset(); + if (!checkWhitespaces(docInfo, offset, docInfo.getEndOffset())) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Garbage characters at end.", offset), offset); + } + return jv; + } + + static JsonValue parseValue(JsonDocumentInfo docInfo, int offset, int index) { + offset = skipWhitespaces(docInfo, offset); + if (offset >= docInfo.getEndOffset()) { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Value not recognized.", offset), offset); + } + return switch (docInfo.charAt(offset)) { + case '{' -> parseObject(docInfo, offset, index); + case '[' -> parseArray(docInfo, offset, index); + case '"' -> parseString(docInfo, offset, index); + case 't', 'f' -> parseBoolean(docInfo, offset, index); + case 'n' -> parseNull(docInfo, offset, index); + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-' -> parseNumber(docInfo, offset, index); + default -> throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Invalid value.", offset), offset); + }; + } + + static JsonObject parseObject(JsonDocumentInfo docInfo, int offset, int index) { + return new JsonObjectImpl(docInfo, offset, index); + } + + static JsonArray parseArray(JsonDocumentInfo docInfo, int offset, int index) { + return new JsonArrayImpl(docInfo, offset, index); + } + + static JsonString parseString(JsonDocumentInfo docInfo, int offset, int index) { + return new JsonStringImpl(docInfo, offset, index); + } + + static JsonBoolean parseBoolean(JsonDocumentInfo docInfo, int offset, int index) { + return new JsonBooleanImpl(docInfo, offset, index); + } + + static JsonNull parseNull(JsonDocumentInfo docInfo, int offset, int index) { + return new JsonNullImpl(docInfo, offset, index); + } + + static JsonNumber parseNumber(JsonDocumentInfo docInfo, int offset, int index) { + return new JsonNumberImpl(docInfo, offset, index); + } + + // Utility functions + static int skipWhitespaces(JsonDocumentInfo docInfo, int offset) { + while (offset < docInfo.getEndOffset()) { + if (!isWhitespace(docInfo.charAt(offset))) { + break; + } + offset ++; + } + return offset; + } + + static boolean checkWhitespaces(JsonDocumentInfo docInfo, int offset, int endOffset) { + int end = Math.min(endOffset, docInfo.getEndOffset()); + while (offset < end) { + if (!isWhitespace(docInfo.charAt(offset))) { + return false; + } + offset ++; + } + return true; + } + + static boolean isWhitespace(char c) { + return switch (c) { + case ' ', '\t', '\n', '\r' -> true; + default -> false; + }; + } + + // no instantiation of this parser + private JsonParser(){} +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonPathBuilder.java b/src/java.base/share/classes/jdk/internal/util/json/JsonPathBuilder.java new file mode 100644 index 0000000000000..774e3360cc950 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonPathBuilder.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +// this can be user's code outside the JDK, on top of our API +/** + * A path-based accessor to a leaf JsonValue. For example: + * {@snippet lang = java: + * JsonValue doc = JsonParser.parse( + * """ + * [ + * { "name": "John", "age": 30, "city": "New York" }, + * { "name": "Jane", "age": 20, "city": "Boston" } + * ] + * """); + * JsonPathBuilder jpb = new JsonPathBuilder(); + * if (jpb.arrayIndex(1).objectKey("name").build().apply(doc) instanceof JsonString name) { + * // name should be "Jane" + * } + * } + */ +/*public*/ class JsonPathBuilder { + private final List> funcs; + + /** + * Creates a builder + */ + public JsonPathBuilder() { + funcs = new ArrayList<>(); + } + + /** + * Obtains the member value for the specified key in this JsonObject + * + * @param key the key in this JsonObject + * @return this builder + * @throws IllegalStateException if the target JsonValue is not a JsonObject + */ + public JsonPathBuilder objectKey(String key) { + funcs.add(jsonValue -> { + if (jsonValue instanceof JsonObject jo) { + return jo.keys().get(key); + } else { + throw new IllegalStateException("Not a JsonObject: %s".formatted(jsonValue)); + } + }); + return this; + } + + /** + * Obtains the element value for the specified index in this JsonArray + * + * @param index the index in this JsonArray + * @return this builder + * @throws IllegalStateException if the target JsonValue is not a JsonArray + */ + public JsonPathBuilder arrayIndex(int index) { + funcs.add(jsonValue -> { + if (jsonValue instanceof JsonArray ja) { + return ja.values().get(index); + } else { + throw new IllegalStateException("Not a JsonArray: %s".formatted(jsonValue)); + } + }); + return this; + } + + /** + * Clears this builder. + * @return this builder + */ + public JsonPathBuilder clear() { + funcs.clear(); + return this; + } + + /** + * Builds the function to obtain the leaf JsonValue + * @return the function to the leaf JsonValue + */ + public Function build() { + return jsonValue -> { + JsonValue ret = jsonValue; + for (Function f : funcs) { + ret = f.apply(ret); + } + return ret; + }; + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonString.java b/src/java.base/share/classes/jdk/internal/util/json/JsonString.java new file mode 100644 index 0000000000000..7a2cb49baa169 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonString.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Objects; + +/** + * The interface that represents JSON string + */ +public sealed interface JsonString extends JsonValue permits JsonStringImpl { + /** + * {@return the {@code String} value represented with this + * {@code JsonString} value} + */ + String value(); + + /** + * {@return the {@code String} value represented with this + * {@code JsonString} value} + */ + String toUntyped(); + + /** + * {@return the {@code JsonString} created from the given + * {@code String} object} + * + * @param from the given {@code String}. Non-null. + */ + static JsonString fromString(String from) { + Objects.requireNonNull(from); + return new JsonStringImpl(from); + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonStringImpl.java b/src/java.base/share/classes/jdk/internal/util/json/JsonStringImpl.java new file mode 100644 index 0000000000000..f5ae0daa56df3 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonStringImpl.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Objects; + +/** + * JsonString implementation class + */ +final class JsonStringImpl implements JsonString, JsonValueImpl { + private final JsonDocumentInfo docInfo; + private final int startOffset, endOffset; + private final int endIndex; + private String theString; + private String source; + + JsonStringImpl(String str) { + docInfo = new JsonDocumentInfo("\"" + str + "\""); + startOffset = 0; + endOffset = docInfo.getEndOffset(); + endIndex = 0; + } + + JsonStringImpl(JsonDocumentInfo docInfo, int offset, int index) { + this.docInfo = docInfo; + startOffset = offset; + endIndex = docInfo.nextIndex(index); + // First quote is already implicitly matched during parse + if (endIndex != -1 && docInfo.charAtIndex(endIndex) == '"') { + endOffset = docInfo.getOffset(endIndex) + 1; + } else { + throw new JsonParseException(docInfo.composeParseExceptionMessage( + "Dangling quote.", offset), offset); + } + } + + @Override + public String value() { + // Ensure the input is sanitized + if (theString == null) { + theString = docInfo.unescape(startOffset + 1, endOffset - 1); + } + return theString; + } + + @Override + public int getEndOffset() { + return endOffset; + } + + @Override + public int getEndIndex() { + return endIndex; + } + + @Override + public boolean equals(Object o) { + return this == o || + o instanceof JsonStringImpl ojsi && + Objects.equals(toString(), ojsi.toString()); + } + + @Override + public int hashCode() { + return Objects.hash(toString()); + } + + @Override + public String toUntyped() { + return value(); + } + + // toString should return the source input String + @Override + public String toString() { + return formatCompact(); + } + + @Override + public String formatCompact() { + value(); // Call to sanitize input + if (source == null) { + source = docInfo.substring(startOffset, endOffset); + } + return source; + } + + @Override + public String formatReadable() { + return formatReadable(0, false); + } + + @Override + public String formatReadable(int indent, boolean isField) { + return " ".repeat(isField ? 1 : indent) + toString(); + } +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonValue.java b/src/java.base/share/classes/jdk/internal/util/json/JsonValue.java new file mode 100644 index 0000000000000..ada4a2ba2f0d9 --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonValue.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * The interface that represents JSON value + */ +public sealed interface JsonValue permits JsonString, JsonNumber, JsonObject, JsonArray, JsonBoolean, JsonNull { + /** + * {@return an Object that represents the data} Actual data type depends + * on the subtype of this interface. + */ + Object toUntyped(); + + /** + * {@return a JsonValue that represents the data} Actual data type depends + * on the subtype of this interface. + * + * @param from the data to produce the JsonValue from. May be null. + * @throws IllegalArgumentException if {@code from} cannot be converted + * to any of {@code JsonValue} subtypes. + * @throws StackOverflowError if {@code from} contains a circular reference + */ + static JsonValue fromUntyped(Object from) { + return switch (from) { + case String str -> JsonString.fromString(str); + case Map map -> JsonObject.fromUntyped(map); + case List list-> JsonArray.fromUntyped(list); + case Object[] array -> JsonArray.fromUntyped(Arrays.asList(array)); + case Boolean bool -> JsonBoolean.fromBoolean(bool); + case Number num-> JsonNumber.fromNumber(num); + case null -> JsonNull.ofNull(); + default -> throw new IllegalArgumentException("Type not recognized."); + }; + } + + /** + * {@return the String representation of this {@code JsonValue} that conforms + * to the JSON syntax} The output String is as compact as possible, which does + * not contain any white spaces or line-breaks. + */ + String formatCompact(); + + /** + * {@return the String representation of this {@code JsonValue} that conforms + * to the JSON syntax} The output String is human-readable, which involves + * indentation, spacing, and line-breaks. + */ + String formatReadable(); +} diff --git a/src/java.base/share/classes/jdk/internal/util/json/JsonValueImpl.java b/src/java.base/share/classes/jdk/internal/util/json/JsonValueImpl.java new file mode 100644 index 0000000000000..0e4f424eaaa9f --- /dev/null +++ b/src/java.base/share/classes/jdk/internal/util/json/JsonValueImpl.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.util.json; + +/** + * Implementation methods common to JsonXXXImpl classes + */ +sealed interface JsonValueImpl permits JsonArrayImpl, JsonBooleanImpl, JsonNullImpl, JsonNumberImpl, JsonObjectImpl, JsonStringImpl { + int INDENT = 2; + int getEndOffset(); + int getEndIndex(); + String formatReadable(int index, boolean isField); +}