personthecat.catlib.serialization.json.XjsUtils Maven / Gradle / Ivy
Show all versions of catlib-quilt Show documentation
package personthecat.catlib.serialization.json;
import com.mojang.datafixers.util.Either;
import com.mojang.serialization.Codec;
import lombok.experimental.UtilityClass;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jetbrains.annotations.Nullable;
import personthecat.catlib.command.arguments.PathArgument;
import personthecat.catlib.exception.JsonFormatException;
import personthecat.catlib.serialization.codec.XjsOps;
import personthecat.fresult.Result;
import personthecat.fresult.Void;
import xjs.comments.CommentType;
import xjs.core.*;
import xjs.exception.SyntaxException;
import xjs.serialization.JsonContext;
import xjs.serialization.writer.JsonWriterOptions;
import javax.annotation.CheckReturnValue;
import javax.annotation.ParametersAreNonnullByDefault;
import java.io.*;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Function;
import static java.util.Optional.empty;
import static personthecat.catlib.exception.Exceptions.jsonFormatEx;
import static personthecat.catlib.exception.Exceptions.unreachable;
import static personthecat.catlib.util.Shorthand.f;
import static personthecat.catlib.util.Shorthand.full;
import static personthecat.catlib.util.Shorthand.nullable;
/**
* A collection of convenience methods for interacting with XJS objects. Unlike
* the original methods inside of {@link JsonObject}, most of the utilities in this
* class return values wrapped in {@link Optional}, instead of null
.
*
* In a future version of this library (via Exjson/xjs-core), JSON objects will
* support returning {@link Optional} out of the box, as well as the options to
* flatten arrays, support additional data types, and more. As a result, most
* of these utilities will eventually be deprecated.
*
*/
@Log4j2
@UtilityClass
@SuppressWarnings("unused")
@ParametersAreNonnullByDefault
public class XjsUtils {
/**
* Reads a {@link JsonObject} from the given file.
*
* @param file The file containing the serialized JSON object.
* @return The deserialized object, or else {@link Optional#empty}.
*/
public static Optional readJson(final File file) {
return Result
.define(FileNotFoundException.class, Result::WARN)
.define(SyntaxException.class, e -> { throw jsonFormatEx(file.getPath(), e); })
.suppress(() -> Json.parse(file).asObject())
.get();
}
/**
* Reads a {@link JsonObject} from the given input stream.
*
* @param is The stream containing the serialized JSON object.
* @return The deserialized object, or else {@link Optional#empty}.
*/
public static Optional readJson(final InputStream is) {
return Result
.define(IOException.class, Result::WARN)
.define(SyntaxException.class, r -> { throw jsonFormatEx("Reading data"); })
.suppress(() -> Json.parse(is).asObject())
.get();
}
/**
* Variant of {@link #readJson(File)} which ignores syntax errors
* and simply returns {@link Optional#empty} if any error occurs.
*
* @param file The file containing the serialized JSON object.
* @return The deserialized object, or else {@link Optional#empty}.
*/
public static Optional readSuppressing(final File file) {
return Result.suppress(() -> Json.parse(file).asObject()).get(Result::WARN);
}
/**
* Variant of {@link #readSuppressing(File)} which reads directly
* from an {@link InputStream}.
*
* @param is The data containing the serialized JSON object.
* @return The deserialized object, or else {@link Optional#empty}.
*/
public static Optional readSuppressing(final InputStream is) {
return Result.suppress(() -> Json.parse(is).asObject()).get(Result::WARN);
}
/**
* Reads any JSON data from the given string contents.
*
* @param contents The raw JSON data being parsed.
* @return The parsed JSON data, or else {@link Result#err} containing the exception.
*/
public static Result readValue(final String contents) {
return Result.of(() -> Json.parse(contents)).ifErr(Result::IGNORE);
}
/**
* Reads an object from the given data when provided a codec.
*
* @param codec Instructions for deserializing the data.
* @param value The actual data being deserialized.
* @param The type of object being returned.
* @return The deserialized object, or else {@link Optional#empty}.
*/
public static Optional readOptional(final Codec codec, final JsonValue value) {
return codec.parse(XjsOps.INSTANCE, value).result();
}
/**
* Reads an object from the given data, or else throws an exception.
*
* @param codec Instructions for deserializing the data.
* @param value The actual data being deserialized.
* @param The type of object being returned.
* @return The deserialized object.
*/
public static T readThrowing(final Codec codec, final JsonValue value) {
return codec.parse(XjsOps.INSTANCE, value).get().map(Function.identity(), partial -> {
throw new JsonFormatException(partial.message());
});
}
/**
* Writes a regular {@link JsonObject} to the disk. The format of this output file
* is automatically determined by its extension.
*
* Any file extended with .json
will be written in regular JSON
* format. All other extensions will implicitly be treated as XJS.
*
*
* No {@link IOException}s will be thrown by this method. Instead, they will be
* logged and simply returned for the caller to optionally throw.
*
*
* All other exceptions will be thrown by this method.
*
* @param json The JSON data being serialized.
* @param file The destination file containing these data.
* @return A result which potentially contains an error.
*/
public static Result writeJson(final JsonObject json, final File file) {
return Result.with(() -> new FileWriter(file), writer -> { json.write(file); })
.ifErr(e -> log.error("Writing file", e));
}
/**
* Writes the input value as JSON, returning {@link Optional#empty} if any errors
* occur in the process.
*
* @param codec The codec responsible for the serialization.
* @param a The data being serialized.
* @param The type of data being serialized.
* @return The serialized data, or else {@link Optional#empty}.
*/
public static Optional writeSuppressing(final Codec codec, final @Nullable A a) {
if (a == null) return Optional.of(JsonLiteral.jsonNull());
return codec.encodeStart(XjsOps.INSTANCE, a).result();
}
/**
* Writes the input value as JSON, or else throwing an exception if any errors
* occur in the process.
*
* @param codec The codec responsible for the serialization.
* @param a The data being serialized.
* @param The type of data being serialized.
* @return The serialized data.
*/
public static JsonValue writeThrowing(final Codec codec, final @Nullable A a) {
if (a == null) return JsonLiteral.jsonNull();
return codec.encodeStart(XjsOps.INSTANCE, a).result()
.orElseThrow(() -> new JsonFormatException("Writing object: " + a));
}
/**
* Reads a file from the disk and updates it.
*
* For example,
*
* {@code
* XJSTools.updateJson(file, json -> {
* json.set("hello", "world");
* });
* }
*
* The output of this expression will be applied to the original file.
*
* @param file the file containing JSON data.
* @param f Instructions for updating the JSON data.
* @return A result which potentially contains an error.
*/
@CheckReturnValue
public static Result updateJson(final File file, final Consumer f) {
// If #readJson returned empty, it's because the file didn't exist.
final JsonObject json = readJson(file).orElseGet(JsonObject::new);
f.accept(json);
return writeJson(json, file);
}
/**
* Gets the default formatting options, guaranteed to never print a `\r` character,
* which Minecraft does not print correctly in-game.
*
* @return The default formatting options without \r
.
*/
public static JsonWriterOptions noCr() {
return JsonContext.getDefaultFormatting().setEol("\n");
}
/**
* Updates a single value in a JSON object based on a full, dotted path.
*
* For example,
*
*
* /update my_json path.to.field true
*
* @param json The JSON object containing this path.
* @param path The output of a {@link PathArgument}.
* @param value The updated value to set at this path.
*/
public static void setValueFromPath(final JsonObject json, final JsonPath path, @Nullable final JsonValue value) {
if (path.isEmpty()) {
return;
}
final Either lastVal = path.get(path.size() - 1);
final JsonContainer parent = getLastContainer(json, path);
// This will ideally be handled by XJS in the future.
if (value != null && value.getLinesAbove() == -1 && condenseNewValue(path, parent)) {
value.setLinesAbove(0);
}
setEither(parent, lastVal, value);
}
/**
* Determines whether to format an incoming value as condensed.
*
* @param path The path to the value being set.
* @param container The parent container for this new value.
* @return true
, if the value should be condensed.
*/
private static boolean condenseNewValue(final JsonPath path, final JsonContainer container) {
if (container.isEmpty()) {
return true;
}
final int s = path.size() == 1 && container.isObject() ? 1 : 0;
for (int i = s; i < container.size(); i++) {
if (container.getReference(i).getOnly().getLinesAbove() == 0) {
return true;
}
}
return false;
}
/**
* Gets a single value in a JSON object based on a full, dotted path.
*
* @param json The JSON object containing this path.
* @param path The output of a {@link PathArgument}.
* @return The value at this location, or else {@link Optional#empty}.
*/
public static Optional getValueFromPath(final JsonObject json, final JsonPath path) {
if (path.isEmpty()) {
return empty();
}
final Either lastVal = path.get(path.size() - 1);
return getEither(getLastContainer(json, path), lastVal);
}
/**
* Retrieves the last JsonObject or JsonArray represented by this path.
*
* For example, a path of
*
*
* object1.array2.object3.value4
*
*
* will return object3
when passed into this method.
*
*
* If no object or array exists at this location, a new container will be created at this
* location and returned by the method.
*
* @param json The JSON object containing this path.
* @param path The output of a {@link PathArgument}.
* @return The value at this location, the original json
, or else a new container.
*/
public static JsonContainer getLastContainer(final JsonObject json, final JsonPath path) {
if (path.isEmpty()) {
return json;
}
JsonContainer current = json;
for (int i = 0; i < path.size() - 1; i++) {
final Either val = path.get(i);
final Either peek = path.get(i + 1);
if (val.right().isPresent()) { // Index
current = getOrTryNew(current.asArray(), val.right().get(), peek);
} else if (peek.left().isPresent()) { // Key -> key -> object
current = current.asObject()
.getOptional(val.left().orElseThrow(), JsonValue::asObject)
.orElseGet(Json::object);
} else { // Key -> index -> array
current = current.asObject()
.getOptional(val.left().orElseThrow(), JsonValue::asArray)
.orElseGet(Json::array);
}
}
return current;
}
/**
* Gets the index of the last available element in this path, or else -1.
*
* For example, when given the following JSON object:
*
* a:{b:[]}
*
* And the following path:
*
* a.b[0].c
*
* An index of 1 (pointing to b) will be returned.
*
* @param json The JSON object containing the data being inspected.
* @param path The path to the expected data, which may or may not exist.
* @return The index to the last matching element, or else -1.
*/
public static int getLastAvailable(final JsonObject json, final JsonPath path) {
final MutableObject current = new MutableObject<>(json);
int index = -1;
for (final Either component : path) {
component.ifLeft(key -> {
final JsonValue value = current.getValue();
if (value.isObject()) {
current.setValue(value.asObject().get(key));
} else {
current.setValue(null);
}
}).ifRight(i -> {
final JsonValue value = current.getValue();
if (value.isArray() && i < value.asArray().size()) {
current.setValue(value.asArray().get(i));
} else {
current.setValue(null);
}
});
if (current.getValue() == null) {
return index;
}
index++;
}
return index;
}
/**
* Attempts to resolve the closest matching path in the given JSON data.
*
* Essentially, this method accepts the canonicalized path of an expected value for the
* data being represented. It will account for the possibility that object arrays may be
* expressed as singletons and return the actual path, should any be used.
*
* @param json The object being inspected.
* @param path The canonicalized path to the expected value
* @return The actual path to the value, or else the canonical path.
*/
public static JsonPath getClosestMatch(final JsonObject json, final JsonPath path) {
final MutableObject current = new MutableObject<>(json);
final JsonPath.JsonPathBuilder builder = JsonPath.builder();
for (int i = 0; i < path.size(); i++) {
path.get(i).ifLeft(key -> {
JsonValue value = current.getValue();
while (value.isArray() && !value.asArray().isEmpty()) {
builder.index(0);
value = value.asArray().get(0);
}
if (value.isObject() && value.asObject().has(key)) {
current.setValue(value.asObject().get(key));
builder.key(key);
} else {
current.setValue(null);
}
}).ifRight(index -> {
final JsonValue value = current.getValue();
if (value.isArray() && value.asArray().size() > index) {
current.setValue(value.asArray().get(index));
builder.index(index);
} else if (!(value.isObject() && index == 0)) {
current.setValue(null);
}
});
if (current.getValue() == null) {
return builder.build().append(path, i);
}
}
return builder.build();
}
/**
* Filters values from the given JSON object according to a list of expected paths.
*
* @param json The JSON object and source being transformed.
* @param paths The paths expected to stay in the output.
* @return A transformed object containing only the expected paths.
*/
public static JsonObject filter(final JsonObject json, final Collection paths) {
return filter(json, paths, false);
}
/**
* Filters values from the given JSON object according to a list of expected paths.
*
* @param json The JSON object and source being transformed.
* @param paths The paths expected to stay in the output.
* @param blacklist Whether to optionally blacklist these paths.
* @return A transformed object containing only the expected paths.
*/
public static JsonObject filter(final JsonObject json, final Collection paths, final boolean blacklist) {
final JsonObject clone = (JsonObject) json.deepCopy();
// Flag each path as used so anything else will get removed.
paths.forEach(path -> path.getValue(clone));
return skip(clone, blacklist);
}
/**
* Generates a new {@link JsonObject} containing only the values that were or were not
* used in the original.
*
* @param json The original JSON object being transformed.
* @param used true
to skip used values, false
to skip unused.
* @return A new JSON object with these values trimmed out.
*/
public static JsonObject skip(final JsonObject json, final boolean used) {
final JsonObject generated = (JsonObject) new JsonObject().setDefaultMetadata(json);
final StringBuilder skipped = new StringBuilder();
for (final JsonObject.Member member : json) {
final JsonValue value = member.getOnly();
final String name = member.getKey();
if (member.getReference().isAccessed() != used) {
if (skipped.length() > 0) {
value.prependComment("Skipped " + skipped);
skipped.setLength(0);
}
if (value.isObject()) {
generated.add(name, skip(value.asObject(), used));
} else if (value.isArray()) {
generated.add(name, skip(value.asArray(), used));
} else {
generated.add(name, value);
}
} else if (skipped.length() == 0) {
skipped.append(name);
} else {
skipped.append(", ").append(name);
}
}
if (skipped.length() > 0) {
generated.prependComment(CommentType.INTERIOR, "Skipped " + skipped);
}
return generated;
}
/**
* Generates a new {@link JsonArray} containing only the values that were or were not
* used in the original.
*
* @param json The original JSON array being transformed.
* @param used true
to skip used values, false
to skip unused.
* @return A new JSON array with these values trimmed out.
*/
public static JsonArray skip(final JsonArray json, final boolean used) {
final JsonArray generated = (JsonArray) new JsonArray().setDefaultMetadata(json);
int lastIndex = 0;
int index = 0;
for (final JsonReference reference : json.references()) {
final JsonValue value = reference.getOnly();
if (reference.isAccessed() != used) {
if (index == lastIndex + 1) {
value.prependComment("Skipped " + (index - 1));
} else if (index > lastIndex) {
value.prependComment("Skipped " + lastIndex + " ~ " + (index - 1));
}
if (value.isObject()) {
generated.add(skip(value.asObject(), used));
} else if (value.isArray()) {
generated.add(skip(value.asArray(), used));
} else {
generated.add(value);
}
lastIndex = index + 1;
}
index++;
}
if (index == lastIndex + 1) {
generated.prependComment(CommentType.INTERIOR, "Skipped " + (index - 1));
} else if (index > lastIndex) {
generated.prependComment(CommentType.INTERIOR, "Skipped " + lastIndex + " ~ " + (index - 1));
}
return generated;
}
/**
* Retrieves a list of paths adjacent to the input path. This can be used to provide
* command suggestions as the user is walking through this container.
*
* For example, when given the following JSON object:
*
*
* a: [
* {
* b: { b1: true }
* c: { c1: false }
* }
* ]
*
*
* and the following incomplete command:
*
*
* /update my_json a[0]
*
*
* the following paths will be returned:
*
*
* - a[0].b
* - a[0].c
*
* @param json The JSON data containing these paths.
* @param path The current output of a {@link PathArgument}.
* @return A list of all adjacent paths.
*/
public static List getPaths(final JsonObject json, final JsonPath path) {
final JsonValue container = Result.of(() -> getLastContainer(json, path))
.get(Result::WARN)
.orElse(json);
int end = path.size() - 1;
if (end < 0) {
return getNeighbors("", container);
}
final Optional v = getEither(container, path.get(end))
.filter(value -> value.isObject() || value.isArray());
if (v.isPresent()) {
end++; // The full path is a valid container -> use it.
}
final String dir = JsonPath.serialize(path.subList(0, end));
return getNeighbors(dir, v.orElse(container));
}
/**
* Retrieves a list of paths in the given container.
*
* @param dir The path to this container, as a string.
* @param container The {@link JsonObject} or {@link JsonArray} at this location.
* @return A formatted list of all members at this location.
*/
private static List getNeighbors(final String dir, final JsonValue container) {
final List neighbors = new ArrayList<>();
if (container.isObject()) {
for (JsonObject.Member member : container.asObject()) {
final String name = member.getKey();
neighbors.add(dir.isEmpty() ? name : f("{}.{}", dir, name));
}
} else if (container.isArray()) {
for (int i = 0; i < container.asArray().size(); i++) {
neighbors.add(f("{}[{}]", dir, i));
}
}
return neighbors;
}
/**
* Attempts to retrieve an object or an array. Creates a new one, if absent.
*
* @throws IndexOutOfBoundsException If index > array.size()
* @param array The JSON array containing the researched data.
* @param index The index of the data in the array.
* @param type The path element at this index, indicating either a key or an index.
* @return Either a JSON object or array, whichever is at this location.
*/
private static JsonContainer getOrTryNew(final JsonArray array, final int index, final Either type) {
if (index == array.size()) { // The value must be added.
type.ifLeft(s -> array.add(new JsonObject()))
.ifRight(i -> array.add(new JsonArray()));
} // if index >= newSize -> index out of bounds
return array.get(index).asContainer();
}
/**
* Attempts to retrieve either an object or an array from a JSON container.
*
* If this value is a string, it will be treated as a key. If the value is a
* number, it will be treated as an index.
*
* @param container Either a JSON object or array
* @param either The accessor for the value at this location.
*/
private static Optional getEither(final JsonValue container, final Either either) {
if (either.left().isPresent()) {
return nullable(container.asObject().get(either.left().get()));
} else if (either.right().isPresent()) {
final JsonArray array = container.asArray();
final int index = either.right().get();
return index < array.size() ? full(array.get(index)) : empty();
}
throw unreachable();
}
/**
* Attempts to set a value in a container which may either be an object or an array.
*
* @param container Either a JSON object or array.
* @param either The accessor for this value, either a key or an index.
* @param value The value to set at this location.
*/
private static void setEither(final JsonValue container, final Either either, @Nullable final JsonValue value) {
if (either.left().isPresent()) {
if (value == null) {
container.asObject().remove(either.left().get());
} else if (value.hasComments()) {
container.asObject().set(either.left().get(), value);
} else {
final String key = either.left().get();
final JsonObject object = container.asObject();
object.set(key, value);
}
} else if (either.right().isPresent()) { // Just to stop the linting.
if (value == null) {
container.asArray().remove(either.right().get());
} else if (value.hasComments()) {
container.asArray().set(either.right().get(), value);
} else {
final int index = either.right().get();
final JsonArray array = container.asArray();
setOrAdd(array, index, value);
}
}
}
/**
* Adds a value to an array by name. The value will be coerced into an array, if needed.
*
* For example, when adding a string to the following JSON field:
*
*
* field: hello
*
*
* the field will be updated as follows:
*
*
* field: [
* hello
* world
* ]
*
* @param json The JSON object containing these data.
* @param field The key for updating an array.
* @param value The value being added to the array.
* @return The original json
passed in.
*/
public static JsonObject addToArray(final JsonObject json, final String field, final JsonValue value) {
JsonValue array = json.get(field);
if (array == null) {
array = new JsonArray();
json.add(field, array);
} else if (!array.isArray()) {
array = new JsonArray().add(array);
json.set(field, array);
}
array.asArray().add(value);
return json;
}
/**
* Sets the value at the given index, or else if index == array.size()
, adds it.
*
* @param array The array being added into.
* @param index The index of the value being set.
* @param value The value being set.
* @return array
, for method chaining.
* @throws IndexOutOfBoundsException If index < 0 || index > size
*/
public static JsonArray setOrAdd(final JsonArray array, final int index, final JsonValue value) {
if (index == array.size()) {
return array.add(value);
}
return array.set(index, value);
}
/**
* Returns a list of {@link JsonObject}s from the given source.
*
* Note that the values in this array will be coerced into {@link JsonObject}s.
*
*
* These objects can be stored in any number of dimensions, but will be coerced
* into a single dimensional array. For example, each of the following values will
* yield single dimensional object arrays:
*
*
* array: [{},{},{}]
* array: [[{}],[[{}]]]
* array: {}
*
* @param json The JSON parent containing the array.
* @param field The field where this array is stored.
* @return The JSON array in the form of a regular list.
*/
public static List getObjectArray(final JsonObject json, final String field) {
final List array = new ArrayList<>();
json.getOptional(field).map(JsonValue::intoArray)
.ifPresent(a -> flatten(array, a));
return array;
}
/**
* Recursively flattens object arrays into a single dimension.
*
* @param array The list of JSON objects being accumulated into.
* @param source The original JSON array data source.
*/
private static void flatten(final List array, final JsonArray source) {
for (final JsonValue value: source) {
if (value.isArray()) {
flatten(array, value.asArray());
} else if (value.isObject()) {
array.add(value.asObject());
} else {
throw jsonFormatEx("Expected an array or object: {}", value);
}
}
}
/**
* Variant of {@link #getObjectArray} which does not coerce values into objects.
*
* Note that any non-object values in this array will not be returned.
*
*
* For example, when given the following JSON array:
*
*
* array: [{},{},true,[[{}]]]
*
*
* This array will be returned:
*
*
* [{},{},{}]
*
* @param json The JSON object containing the array.
* @param field The key where this array is stored.
* @return A list of all {@link JsonObject}s at this location.
*/
public static List getRegularObjects(final JsonObject json, final String field) {
final List list = new ArrayList<>();
final JsonArray array = json.getOptional(field)
.map(JsonValue::intoArray)
.orElseGet(JsonArray::new);
flattenRegularObjects(list, array);
return list;
}
/**
* Variant of {@link #flatten} which does not coerce values into objects.
*
* @param array The list of JSON objects being accumulated into.
* @param source The original JSON array data source.
*/
private static void flattenRegularObjects(final List array, final JsonArray source) {
for (final JsonValue value: source) {
if (value.isArray()) {
flattenRegularObjects(array, value.asArray());
} else if (value.isObject()) {
array.add(value.asObject());
}
}
}
/**
* Gets an array for the given key, or else adds a new array into the object and returns it.
*
* @param json The JSON object being inspected.
* @param field The name of the array being queried.
* @return The existing or new array.
*/
public static JsonArray getOrCreateArray(final JsonObject json, final String field) {
if (json.get(field) instanceof JsonArray array) {
return array;
}
final JsonArray array = Json.array();
json.set(field, array);
return array;
}
/**
* Gets and object for the given key, or else adds a new object into the container and returns it.
*
* @param json The JSON object being inspected.
* @param field The name of the object being queried.
* @return The existing or new object.
*/
public static JsonObject getOrCreateObject(final JsonObject json, final String field) {
if (json.get(field) instanceof JsonObject object) {
return object;
}
final JsonObject object = Json.object();
json.set(field, object);
return object;
}
}