390 lines
12 KiB
Java
390 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
|
|
&& context != EMPTY_DOCUMENT && context != NONEMPTY_DOCUMENT)
|
|
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
|
|
|| context == EMPTY_DOCUMENT || context == NONEMPTY_DOCUMENT) {
|
|
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.");
|
|
}
|
|
}
|
|
}
|