java-commons/commons-serialize-json/src/main/java/io/gitlab/jfronny/commons/serialize/json/JsonWriter.java

387 lines
12 KiB
Java

package io.gitlab.jfronny.commons.serialize.json;
import io.gitlab.jfronny.commons.serialize.StringEscapeUtil;
import io.gitlab.jfronny.commons.serialize.MalformedDataException;
import io.gitlab.jfronny.commons.serialize.SerializeWriter;
import java.io.Closeable;
import java.io.IOException;
import java.io.Writer;
import java.util.*;
import static io.gitlab.jfronny.commons.serialize.json.impl.JsonScope.*;
public class JsonWriter extends SerializeWriter<IOException, JsonWriter> implements Closeable {
private final Writer out;
private int[] stack = new int[32];
private int stackSize = 0;
{
push(EMPTY_DOCUMENT);
}
private String newline;
private String indent;
private String formattedColon;
private String formattedComma;
private boolean usesEmptyNewlineAndIndent;
private boolean omitQuotes = false;
private String deferredName;
private final List<String> deferredComments = new LinkedList<>();
private boolean commentUnexpectedNames = false;
public JsonWriter(Writer out) {
this.out = Objects.requireNonNull(out, "out == null");
newline = indent = "";
setIndent("");
setNewline("");
}
public JsonWriter setIndent(String indent) {
if (indent == null || indent.isEmpty()) {
this.indent = "";
this.formattedColon = ":";
this.formattedComma = ",";
this.usesEmptyNewlineAndIndent = newline.isEmpty();
} else {
this.newline = "\n"; // if someone sets an indent, this is probably intended
this.indent = indent;
this.formattedColon = ": ";
this.formattedComma = ",";
this.usesEmptyNewlineAndIndent = false;
}
return this;
}
public String getIndent() {
return indent;
}
public JsonWriter setNewline(String newline) {
if (newline == null || newline.isEmpty()) {
this.newline = "";
this.formattedComma = indent.isEmpty() ? "," : ", ";
this.usesEmptyNewlineAndIndent = indent.isEmpty();
} else {
this.newline = newline;
this.formattedComma = ",";
this.usesEmptyNewlineAndIndent = false;
}
return this;
}
public String getNewline() {
return newline;
}
public JsonWriter setOmitQuotes(boolean omitQuotes) {
this.omitQuotes = omitQuotes;
return this;
}
public boolean isOmitQuotes() {
return omitQuotes;
}
public JsonWriter setCommentUnexpectedNames(boolean commentUnexpectedNames) {
this.commentUnexpectedNames = commentUnexpectedNames;
return this;
}
public boolean isCommentUnexpectedNames() {
return commentUnexpectedNames;
}
@Override
public JsonWriter beginArray() throws IOException {
writeDeferredName();
return openScope(EMPTY_ARRAY, '[');
}
@Override
public JsonWriter endArray() throws IOException {
return closeScope(EMPTY_ARRAY, NONEMPTY_ARRAY, ']');
}
@Override
public JsonWriter beginObject() throws IOException {
writeDeferredName();
return openScope(EMPTY_OBJECT, '{');
}
@Override
public JsonWriter endObject() throws IOException {
return closeScope(EMPTY_OBJECT, NONEMPTY_OBJECT, '}');
}
private JsonWriter openScope(int empty, char openBracket) throws IOException {
beforeValue();
push(empty);
out.write(openBracket);
return this;
}
private JsonWriter closeScope(int empty, int nonempty, char closeBracket) throws IOException {
int context = peek();
if (context != nonempty && context != empty) {
throw new IllegalStateException("Nesting problem.");
}
if (deferredName != null) {
if (lenient) nullValue();
else throw new IllegalStateException("Dangling name: " + deferredName);
}
if (!deferredComments.isEmpty()) {
newline();
writeDeferredComment();
context = nonempty;
}
stackSize--;
if (context == nonempty) {
newline();
}
out.write(closeBracket);
return this;
}
private void push(int newTop) {
if (stackSize == stack.length) {
stack = Arrays.copyOf(stack, stackSize * 2);
}
stack[stackSize++] = newTop;
}
/** Returns the value on the top of the stack. */
private int peek() {
if (stackSize == 0) {
throw new IllegalStateException("JsonWriter is closed.");
}
return stack[stackSize - 1];
}
/** Replace the value on the top of the stack with the given value. */
private void replaceTop(int topOfStack) {
stack[stackSize - 1] = topOfStack;
}
@Override
public JsonWriter comment(String comment) throws IOException {
if (!lenient) throw new MalformedDataException("Cannot write comment in non-lenient JsonWriter.");
if (comment == null || comment.isBlank()) return this;
String[] parts = comment.split("\n");
Collections.addAll(deferredComments, parts);
if (peek() == NONEMPTY_DOCUMENT) {
newline();
writeDeferredComment();
}
return this;
}
private void writeDeferredComment() throws IOException {
if (!deferredComments.isEmpty()) {
if (newline.isEmpty()) {
out.append("/* ")
.append(String.join(" / ", deferredComments.stream()
.filter(s -> s != null && !s.isBlank())
.map(s -> s.replace("/*", "#/"))
.toList())
).append(" */");
} else {
boolean first = true;
for (String s : deferredComments) {
if (!first) newline();
first = false;
out.append("// ").append(s);
}
}
deferredComments.clear();
}
}
@Override
public JsonWriter name(String name) throws IOException {
Objects.requireNonNull(name, "name == null");
if (deferredName != null) {
throw new IllegalStateException("Already wrote a name, expecting a value.");
}
int context = peek();
if (context != EMPTY_OBJECT && context != NONEMPTY_OBJECT) {
if (lenient) {
if (context != EMPTY_ARRAY && context != NONEMPTY_ARRAY) throw new IllegalStateException("Please begin an object or array before writing a name.");
} else {
throw new IllegalStateException("Please begin an object before writing a name.");
}
}
deferredName = name;
return this;
}
private void writeDeferredName() throws IOException {
if (deferredName != null) {
int context = peek();
if (context == EMPTY_ARRAY || context == NONEMPTY_ARRAY) {
if (commentUnexpectedNames) {
// Write the name as a comment instead of literally
comment(deferredName);
}
} else {
beforeName();
if (omitQuotes && deferredName.matches("[a-zA-Z_$][\\w$]*")) {
out.write(deferredName);
} else {
string(deferredName);
}
}
deferredName = null;
}
}
@Override
public JsonWriter value(String value) throws IOException {
if (value == null) {
return nullValue();
}
writeDeferredName();
beforeValue();
string(value);
return this;
}
@Override
public JsonWriter literalValue(String value) throws IOException {
if (value == null) {
return nullValue();
}
writeDeferredName();
beforeValue();
out.append(value);
return this;
}
@Override
public void flush() throws IOException {
if (stackSize == 0) {
throw new IllegalStateException("JsonWriter is closed.");
}
out.flush();
}
@Override
public void close() throws IOException {
out.close();
int size = stackSize;
if (size > 1 || (size == 1 && stack[size - 1] != NONEMPTY_DOCUMENT)) {
throw new IOException("Incomplete document");
}
stackSize = 0;
}
private void string(String value) throws IOException {
out.write('\"');
int last = 0;
int length = value.length();
for (int i = 0; i < length; i++) {
char c = value.charAt(i);
String replacement = StringEscapeUtil.getReplacement(c);
if (replacement == null) {
continue;
}
if (last < i) {
out.write(value, last, i - last);
}
out.write(replacement);
last = i + 1;
}
if (last < length) {
out.write(value, last, length - last);
}
out.write('\"');
}
private void newline() throws IOException {
if (usesEmptyNewlineAndIndent) {
return;
}
out.write(newline);
for (int i = 1, size = stackSize; i < size; i++) {
out.write(indent);
}
}
/**
* Inserts any necessary separators and whitespace before a name. Also adjusts the stack to expect
* the name's value.
*/
private void beforeName() throws IOException {
int context = peek();
if (context == NONEMPTY_OBJECT) { // first in object
out.write(formattedComma);
} else if (context != EMPTY_OBJECT) { // not in an object!
throw new IllegalStateException("Nesting problem.");
}
newline();
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
replaceTop(DANGLING_NAME);
}
/**
* Inserts any necessary separators and whitespace before a literal value, inline array, or inline
* object. Also adjusts the stack to expect either a closing bracket or another element.
*/
@SuppressWarnings("fallthrough")
private void beforeValue() throws IOException {
switch (peek()) {
case NONEMPTY_DOCUMENT:
if (!lenient) {
throw new IllegalStateException("JSON must have only one top-level value.");
}
// fall-through
case EMPTY_DOCUMENT: // first in document
replaceTop(NONEMPTY_DOCUMENT);
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
break;
case EMPTY_ARRAY: // first in array
replaceTop(NONEMPTY_ARRAY);
newline();
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
break;
case NONEMPTY_ARRAY: // another in array
out.append(formattedComma);
newline();
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
break;
case DANGLING_NAME: // value for name
out.append(formattedColon);
if (!deferredComments.isEmpty()) {
newline();
writeDeferredComment();
newline();
}
replaceTop(NONEMPTY_OBJECT);
break;
default:
throw new IllegalStateException("Nesting problem.");
}
}
}