Compare commits

...

7 Commits

7 changed files with 866 additions and 41 deletions

View File

@ -182,7 +182,12 @@ public class JsonWriter extends SerializeWriter<IOException, JsonWriter> impleme
private void writeDeferredComment() throws IOException {
if (!deferredComments.isEmpty()) {
if (newline.isEmpty()) {
out.append("/* ").append(String.join(" / ", deferredComments)).append(" */");
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) {

View File

@ -2,6 +2,7 @@ package io.gitlab.jfronny.commons.serialize.xml;
import io.gitlab.jfronny.commons.serialize.MalformedDataException;
import io.gitlab.jfronny.commons.serialize.StringEscapeUtil;
import io.gitlab.jfronny.commons.serialize.xml.impl.NameCheck;
import io.gitlab.jfronny.commons.serialize.xml.impl.XmlScope;
import java.io.Closeable;
@ -230,7 +231,7 @@ public class NativeXmlReader implements Closeable {
// fall through
} else if (pos < limit || fillBuffer(1)) {
char chNext = buffer[pos + 1];
var check = isNameStart((char) c, chNext);
var check = NameCheck.isNameStart((char) c, chNext);
pos--;
if (check != NameCheck.NONE) {
return peeked = PEEKED_ATTRIBUTE_NAME;
@ -289,7 +290,7 @@ public class NativeXmlReader implements Closeable {
}
}
} else if (pos + 2 <= limit || fillBuffer(2)) {
var check = isNameStart(chNext, buffer[pos + 1]);
var check = NameCheck.isNameStart(chNext, buffer[pos + 1]);
if (check != NameCheck.NONE) {
return peeked = PEEKED_BEGIN_TAG;
}
@ -302,33 +303,6 @@ public class NativeXmlReader implements Closeable {
}
}
private enum NameCheck { FIRST, BOTH, NONE }
private NameCheck isNameStart(char ch, char chNext) {
if ('A' <= ch && ch <= 'Z') return NameCheck.FIRST;
if ('a' <= ch && ch <= 'z') return NameCheck.FIRST;
return switch (ch) {
case ':', '_' -> NameCheck.FIRST;
case '\u2070' -> chNext == '\u218F' ? NameCheck.BOTH : NameCheck.NONE;
case '\u2C00' -> chNext == '\u2FEF' ? NameCheck.BOTH : NameCheck.NONE;
case '\u3001' -> chNext == '\uD7FF' ? NameCheck.BOTH : NameCheck.NONE;
case '\uF900' -> chNext == '\uFDCF' ? NameCheck.BOTH : NameCheck.NONE;
case '\uFDF0' -> chNext == '\uFFFD' ? NameCheck.BOTH : NameCheck.NONE;
default -> NameCheck.NONE;
};
}
private NameCheck isName(char ch, char chNext) {
var nameStart = isNameStart(ch, chNext);
if (nameStart != NameCheck.NONE) return nameStart;
if ('0' <= ch && ch <= '9') return NameCheck.FIRST;
return switch (ch) {
case '-', '.', '\u00B7' -> NameCheck.FIRST;
case '\u0300' -> chNext == '\u036F' ? NameCheck.BOTH : NameCheck.NONE;
case '\u203F' -> chNext == '\u2040' ? NameCheck.BOTH : NameCheck.NONE;
default -> NameCheck.NONE;
};
}
public String nextAttributeName() throws IOException {
int p = peeked;
if (p == PEEKED_NONE) {
@ -473,7 +447,7 @@ public class NativeXmlReader implements Closeable {
}
}
case PEEKED_ATTRIBUTE_NAME -> {
skipUntil((c, i) -> isName(c, pos + i + 1 < limit ? buffer[pos + i + 1] : '\0') == NameCheck.NONE);
skipUntil((c, i) -> NameCheck.isName(c, pos + i + 1 < limit ? buffer[pos + i + 1] : '\0') == NameCheck.NONE);
if (count == 0) pathNames[stackSize - 1] = "<skipped>";
peeked = PEEKED_NONE;
}
@ -494,7 +468,7 @@ public class NativeXmlReader implements Closeable {
}
private String nextName() throws IOException {
return readUntil((c, i) -> isName(c, pos + i + 1 < limit ? buffer[pos + i + 1] : '\0') == NameCheck.NONE, false);
return readUntil((c, i) -> NameCheck.isName(c, pos + i + 1 < limit ? buffer[pos + i + 1] : '\0') == NameCheck.NONE, false);
}
@FunctionalInterface

View File

@ -0,0 +1,392 @@
package io.gitlab.jfronny.commons.serialize.xml;
import io.gitlab.jfronny.commons.serialize.xml.impl.NameCheck;
import java.io.Closeable;
import java.io.Flushable;
import java.io.IOException;
import java.io.Writer;
import java.util.*;
import static io.gitlab.jfronny.commons.serialize.xml.impl.XmlScope.*;
public class NativeXmlWriter implements Closeable, Flushable {
private final Writer out;
private int[] stack = new int[32];
private int stackSize = 0;
{
push(EMPTY_DOCUMENT);
}
private String[] pathNames = new String[32];
private String newline;
private String indent;
private boolean usesEmptyNewlineAndIndent;
private final List<String> deferredComments = new LinkedList<>();
private String deferredText = null;
private boolean wasText = false;
private boolean lenient = false;
private boolean escapeNonAscii = true;
public NativeXmlWriter(Writer out) {
this.out = Objects.requireNonNull(out, "out == null");
newline = indent = "";
setIndent("");
setNewline("");
}
public NativeXmlWriter setLenient(boolean lenient) {
this.lenient = lenient;
return this;
}
public boolean isLenient() {
return lenient;
}
public NativeXmlWriter setEscapeNonAscii(boolean escapeNonAscii) {
this.escapeNonAscii = escapeNonAscii;
return this;
}
public boolean isEscapeNonAscii() {
return escapeNonAscii;
}
public NativeXmlWriter setIndent(String indent) {
if (indent == null || indent.isEmpty()) {
this.indent = "";
this.usesEmptyNewlineAndIndent = newline.isEmpty();
} else {
this.newline = "\n"; // if someone sets an indent, this is probably intended
this.indent = indent;
this.usesEmptyNewlineAndIndent = false;
}
return this;
}
public String getIndent() {
return indent;
}
public NativeXmlWriter setNewline(String newline) {
if (newline == null || newline.isEmpty()) {
this.newline = "";
this.usesEmptyNewlineAndIndent = indent.isEmpty();
} else {
this.newline = newline;
this.usesEmptyNewlineAndIndent = false;
}
return this;
}
public String getNewline() {
return newline;
}
public NativeXmlWriter beginTag(String name) throws IOException {
Objects.requireNonNull(name, "name == null");
wasText = false;
beforeValue();
pathNames[stackSize - 1] = name;
push(TAG_HEAD);
out.write('<');
name(name);
return this;
}
public NativeXmlWriter endTag() throws IOException {
int context = peek();
if (context == DANGLING_NAME) {
throw new IllegalStateException("Dangling name.");
}
if (context != TAG_HEAD && context != TAG_BODY) {
throw new IllegalStateException("Nesting problem.");
}
wasText = false;
boolean simple = false; // true if the last tag only contained simple text
if (!deferredComments.isEmpty()) {
if (context == TAG_HEAD && deferredComments.size() == 1) {
out.write('>');
if (deferredText == null) simple = true;
else newline();
writeDeferredComment();
} else {
if (context == TAG_HEAD) out.write('>');
newline();
writeDeferredComment();
}
context = TAG_BODY;
}
if (deferredText != null) {
if (context == TAG_HEAD) {
out.write('>');
escapeText(deferredText, true);
context = TAG_BODY;
simple = true;
} else text(deferredText);
deferredText = null;
}
stackSize--;
if (context == TAG_BODY) {
if (!simple) newline();
out.write("</");
name(pathNames[stackSize - 1]);
out.write('>');
} else {
out.write("/>");
}
pathNames[stackSize - 1] = null;
return this;
}
private void push(int newTop) {
if (stackSize == stack.length) {
int newLength = stackSize * 2;
stack = Arrays.copyOf(stack, newLength);
pathNames = Arrays.copyOf(pathNames, newLength);
}
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;
}
public NativeXmlWriter comment(String comment) throws IOException {
if (comment == null || comment.isBlank()) return this;
wasText = false;
comment = comment.replace("-->", "--&gt;");
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()).toList())
).append(" -->");
} else {
boolean first = true;
for (String s : deferredComments) {
if (!first) newline();
first = false;
if (s == null || s.isBlank()) out.append("<!---->");
else out.append("<!-- ").append(s).append(" -->");
}
}
deferredComments.clear();
}
}
public NativeXmlWriter attribute(String name, String value) throws IOException {
return attributeName(name).attributeValue(value);
}
public NativeXmlWriter attributeName(String name) throws IOException {
Objects.requireNonNull(name, "name == null");
if (peek() != TAG_HEAD) {
throw new IllegalStateException("Nesting problem.");
}
wasText = false;
replaceTop(DANGLING_NAME);
out.write(' ');
name(name);
return this;
}
public NativeXmlWriter attributeValue(String value) throws IOException {
value = value == null ? "null" : value;
if (peek() != DANGLING_NAME) {
throw new IllegalStateException("Nesting problem.");
}
wasText = false;
replaceTop(TAG_HEAD);
out.write('=');
out.write('"');
escapeText(value, lenient);
out.write('"');
return this;
}
public NativeXmlWriter text(String text) throws IOException {
text = text == null ? "null" : text;
if (peek() == TAG_HEAD && !text.contains("\n") && deferredText == null && deferredComments.isEmpty()) {
deferredText = text;
wasText = true;
return this;
}
if (wasText && deferredComments.isEmpty()) deferredComments.add("");
beforeValue();
escapeText(text, true);
wasText = true;
return this;
}
public NativeXmlWriter reference(String reference) throws IOException {
reference = reference == null ? "" : reference;
if (!reference.matches("[a-zA-Z_:][a-zA-Z0-9._:-]*")) {
throw new IllegalArgumentException("Invalid reference: " + reference);
}
wasText = false;
beforeValue();
out.write("&");
out.write(reference);
out.write(";");
return this;
}
public NativeXmlWriter cdata(String cdata) throws IOException {
cdata = cdata == null ? "" : cdata;
if (cdata.contains("]]>")) throw new IllegalArgumentException("CDATA cannot contain ']]>'");
wasText = false;
beforeValue();
out.write("<![CDATA[");
out.write(cdata);
out.write("]]>");
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 name(String name) throws IOException {
if (name == null) throw new NullPointerException("name == null");
if (stackSize == 0) throw new IllegalStateException("JsonWriter is closed.");
// Check name for illegal characters
int last = 0;
int length = name.length();
for (int i = 0; i < length; i++) {
char c = name.charAt(i);
char n = i + 1 < length ? name.charAt(i + 1) : '\0';
NameCheck check = i == 0 ? NameCheck.isNameStart(c, n) : NameCheck.isName(c, n);
switch (check) {
case NONE -> {
if (!lenient) {
throw new IllegalArgumentException("Illegal character in name: " + name);
}
if (last < i) out.write(name, last, i - last);
out.write('_');
last = i + 1;
}
case BOTH -> i++;
case FIRST -> {}
}
}
if (last < length) out.write(name, last, length - last);
}
private void escapeText(String text, boolean permitControl) throws IOException {
int last = 0;
int length = text.length();
for (int i = 0; i < length; i++) {
char c = text.charAt(i);
if (!permitControl && c < 0x20 && c != 0x09) throw new IllegalArgumentException("Illegal control character in text: " + text);
String replacement = switch(c) {
case '&' -> "&amp;";
case '<' -> "&lt;";
case '>' -> "&gt;";
case '"' -> "&quot;";
case '\'' -> "&apos;";
default -> escapeNonAscii && c > 127 ? "&#" + (int) c + ";" : null;
};
if (replacement == null) continue;
if (last < i) out.write(text, last, i - last);
out.write(replacement);
last = i + 1;
}
if (last < length) out.write(text, last, length - last);
}
private void newline() throws IOException {
if (usesEmptyNewlineAndIndent) {
return;
}
out.write(newline);
for (int i = 1, size = stackSize; i < size; i++) {
out.write(indent);
}
}
private void beforeValue() throws IOException {
switch (peek()) {
case NONEMPTY_DOCUMENT:
if (!lenient) {
throw new IllegalStateException("XML must have only one top-level value.");
}
case EMPTY_DOCUMENT:
replaceTop(NONEMPTY_DOCUMENT);
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
if (deferredText != null) {
escapeText(deferredText, true);
newline();
deferredText = null;
}
break;
case DANGLING_NAME:
if (!lenient) {
throw new IllegalStateException("Attribute name must be followed by a value");
}
attributeValue("null");
case TAG_HEAD:
out.write('>');
replaceTop(TAG_BODY);
case TAG_BODY:
newline();
if (deferredText != null) {
escapeText(deferredText, true);
newline();
deferredText = null;
}
if (!deferredComments.isEmpty()) {
writeDeferredComment();
newline();
}
break;
default:
throw new IllegalStateException("Nesting problem.");
}
}
}

View File

@ -0,0 +1,31 @@
package io.gitlab.jfronny.commons.serialize.xml.impl;
public enum NameCheck {
FIRST, BOTH, NONE;
public static NameCheck isNameStart(char ch, char chNext) {
if ('A' <= ch && ch <= 'Z') return NameCheck.FIRST;
if ('a' <= ch && ch <= 'z') return NameCheck.FIRST;
return switch (ch) {
case ':', '_' -> NameCheck.FIRST;
case '\u2070' -> chNext == '\u218F' ? NameCheck.BOTH : NameCheck.NONE;
case '\u2C00' -> chNext == '\u2FEF' ? NameCheck.BOTH : NameCheck.NONE;
case '\u3001' -> chNext == '\uD7FF' ? NameCheck.BOTH : NameCheck.NONE;
case '\uF900' -> chNext == '\uFDCF' ? NameCheck.BOTH : NameCheck.NONE;
case '\uFDF0' -> chNext == '\uFFFD' ? NameCheck.BOTH : NameCheck.NONE;
default -> NameCheck.NONE;
};
}
public static NameCheck isName(char ch, char chNext) {
var nameStart = isNameStart(ch, chNext);
if (nameStart != NameCheck.NONE) return nameStart;
if ('0' <= ch && ch <= '9') return NameCheck.FIRST;
return switch (ch) {
case '-', '.', '\u00B7' -> NameCheck.FIRST;
case '\u0300' -> chNext == '\u036F' ? NameCheck.BOTH : NameCheck.NONE;
case '\u203F' -> chNext == '\u2040' ? NameCheck.BOTH : NameCheck.NONE;
default -> NameCheck.NONE;
};
}
}

View File

@ -0,0 +1,18 @@
package io.gitlab.jfronny.commons.serialize.xml.wrapper;
public interface NameEncoding {
String encode(String name);
String decode(String name);
NameEncoding DEFAULT = new NameEncoding() {
@Override
public String encode(String name) {
return name;
}
@Override
public String decode(String name) {
return name;
}
};
}

View File

@ -38,6 +38,7 @@ public class XmlReader extends SerializeReader<IOException, XmlReader> implement
private String[] pathNames = new String[32];
private int[] pathIndices = new int[32];
private Heuristics heuristics = Heuristics.DEFAULT;
private NameEncoding nameEncoding = NameEncoding.DEFAULT;
private String nextTagNamePath = null;
private String nextTagName = null;
@ -83,6 +84,15 @@ public class XmlReader extends SerializeReader<IOException, XmlReader> implement
return heuristics;
}
public XmlReader setNameEncoding(NameEncoding nameEncoding) {
this.nameEncoding = Objects.requireNonNull(nameEncoding);
return this;
}
public NameEncoding getNameEncoding() {
return nameEncoding;
}
@Override
public XmlReader beginArray() throws IOException {
int p = peeked;
@ -263,15 +273,15 @@ public class XmlReader extends SerializeReader<IOException, XmlReader> implement
}
return switch (p) {
case PEEKED_NAME_ATT -> {
String res = reader.nextAttributeName();
String result = reader.nextAttributeName();
peeked = PEEKED_NONE;
yield res;
yield nameEncoding.decode(result);
}
case PEEKED_NAME_TAG -> {
String res = nextTagName;
if (res == null) throw unexpectedTokenError("a name");
String result = nextTagName;
if (result == null) throw unexpectedTokenError("a name");
peeked = PEEKED_NONE;
yield res;
yield nameEncoding.decode(result);
}
case PEEKED_NAME_VIRTUAL_TEXT -> {
String result = heuristics.guessElementName(reader.getPath(), XmlToken.TEXT);
@ -285,11 +295,11 @@ public class XmlReader extends SerializeReader<IOException, XmlReader> implement
}
case PEEKED_NAME_BEGIN_OBJECT -> {
peeked = PEEKED_BEGIN_OBJECT;
yield nextTagName;
yield nameEncoding.decode(nextTagName);
}
case PEEKED_NAME_BEGIN_ARRAY -> {
peeked = PEEKED_BEGIN_ARRAY;
yield nextTagName;
yield nameEncoding.decode(nextTagName);
}
default -> throw unexpectedTokenError("a name");
};
@ -369,14 +379,41 @@ public class XmlReader extends SerializeReader<IOException, XmlReader> implement
return result;
}
private String getPath(boolean usePreviousPath) {
if (nextTagNamePath != null) return nextTagNamePath;
StringBuilder result = new StringBuilder().append('$');
for (int i = 0; i < stackSize; i++) {
int scope = stack[i];
switch (scope) {
case WrapperScope.ARRAY -> {
int pathIndex = pathIndices[i];
// If index is last path element it points to next array element; have to decrement
if (usePreviousPath && pathIndex > 0 && i == stackSize - 1) {
pathIndex--;
}
result.append('[').append(pathIndex).append(']');
}
case WrapperScope.OBJECT -> {
result.append('.');
if (pathNames[i] != null) {
result.append(pathNames[i]);
}
}
case WrapperScope.OBJECT_VALUE_WRAPPER, WrapperScope.OBJECT_VALUE_WRAPPER_USED, WrapperScope.DOCUMENT -> {}
default -> throw new AssertionError("Unknown scope value: " + scope);
}
}
return result.toString();
}
@Override
public String getPath() {
return nextTagName == null ? reader.getPath() : nextTagNamePath;
return getPath(false);
}
@Override
public String getPreviousPath() {
return getPath(); // TODO this should be different when handling arrays
return getPath(true);
}
@Override

View File

@ -0,0 +1,368 @@
/*
* Copyright (C) 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.gitlab.jfronny.commons.serialize.xml.test;
import io.gitlab.jfronny.commons.serialize.xml.NativeXmlWriter;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.StringWriter;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.fail;
@SuppressWarnings("resource")
public final class NativeXmlWriterTest {
@Test
public void testWriteComments() throws IOException {
String expectedJson = """
<!-- comment at file head -->
<root>
<!-- comment directly after context -->
<a>true</a>
<!-- comment before context -->
<inner att="value">
<!-- comment directly after attribute name -->
<b att="value">
<!-- comment after attribute value -->
false
</b>
<cnt><!-- only comment inside tag --></cnt>
</inner>
<!-- comment before context end -->
</root>
<!-- comment behind the object -->""";
StringWriter sw = new StringWriter();
NativeXmlWriter jw = new NativeXmlWriter(sw);
jw.setLenient(true);
jw.setIndent(" ");
jw.comment("comment at file head")
.beginTag("root")
.comment("comment directly after context")
.beginTag("a")
.text("true")
.endTag()
.comment("comment before context")
.beginTag("inner")
.attributeName("att")
.comment("comment directly after attribute name")
.attributeValue("value")
.beginTag("b")
.attributeName("att")
.attributeValue("value")
.comment("comment after attribute value")
.text("false")
.endTag()
.beginTag("cnt")
.comment("only comment inside tag")
.endTag()
.endTag()
.comment("comment before context end")
.endTag()
.comment("comment behind the object");
jw.close();
assertThat(sw.toString()).isEqualTo(expectedJson);
sw.close();
}
@Test
public void testDefaultStrictness() throws IOException {
NativeXmlWriter jsonWriter = new NativeXmlWriter(new StringWriter());
assertThat(jsonWriter.isLenient()).isEqualTo(false);
jsonWriter.text("false");
jsonWriter.close();
}
// for NativeXmlWriter.setLenient
@Test
public void testSetLenientTrue() throws IOException {
NativeXmlWriter jsonWriter = new NativeXmlWriter(new StringWriter());
jsonWriter.setLenient(true);
assertThat(jsonWriter.isLenient()).isEqualTo(true);
jsonWriter.text("false");
jsonWriter.close();
}
// for NativeXmlWriter.setLenient
@Test
public void testSetLenientFalse() throws IOException {
NativeXmlWriter jsonWriter = new NativeXmlWriter(new StringWriter());
jsonWriter.setLenient(false);
assertThat(jsonWriter.isLenient()).isEqualTo(false);
jsonWriter.text("false");
jsonWriter.close();
}
@Test
public void testEmptyTag() throws IOException {
StringWriter sw = new StringWriter();
NativeXmlWriter jw = new NativeXmlWriter(sw);
jw.beginTag("empty").endTag();
jw.close();
assertThat(sw.toString()).isEqualTo("<empty/>");
}
@Test
public void testTopLevelValueTypes() throws IOException {
StringWriter string5 = new StringWriter();
NativeXmlWriter writert = new NativeXmlWriter(string5);
writert.text("a");
writert.close();
assertThat(string5.toString()).isEqualTo("a");
}
@Test
public void testNameWithoutValue() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
jsonWriter.beginTag("tag");
jsonWriter.attributeName("a");
try {
jsonWriter.endTag();
fail();
} catch (IllegalStateException expected) {
assertThat(expected).hasMessageThat().isEqualTo("Dangling name.");
}
}
@Test
public void testValueWithoutName() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
jsonWriter.beginTag("tag");
try {
jsonWriter.attributeValue("a");
fail();
} catch (IllegalStateException expected) {
assertThat(expected).hasMessageThat().isEqualTo("Nesting problem.");
}
}
@Test
public void testMultipleTopLevelValuesStrict() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
jsonWriter.setLenient(false);
jsonWriter.beginTag("tag").endTag();
IllegalStateException expected =
assertThrows(IllegalStateException.class, () -> jsonWriter.beginTag("tag"));
assertThat(expected).hasMessageThat().isEqualTo("XML must have only one top-level value.");
}
@Test
public void testMultipleTopLevelValuesLenient() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter writer = new NativeXmlWriter(stringWriter);
writer.setLenient(true);
writer.beginTag("tag");
writer.endTag();
writer.beginTag("tag2");
writer.endTag();
writer.close();
assertThat(stringWriter.toString()).isEqualTo("<tag/><tag2/>");
}
@Test
public void testNullName() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
jsonWriter.beginTag("tag");
try {
jsonWriter.attributeName(null);
fail();
} catch (NullPointerException expected) {
}
}
@Test
public void testNullStringValue() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
jsonWriter.beginTag("tag");
jsonWriter.attributeName("a");
jsonWriter.attributeValue(null);
jsonWriter.endTag();
assertThat(stringWriter.toString()).isEqualTo("<tag a=\"null\"/>");
}
@Test
public void testUnicodeLineBreaksEscaped() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
jsonWriter.beginTag("body");
jsonWriter.text("\u2028 \u2029");
jsonWriter.endTag();
// JSON specification does not require that they are escaped, but Gson escapes them for
// compatibility with JavaScript where they are considered line breaks
assertThat(stringWriter.toString()).isEqualTo("<body>&#8232; &#8233;</body>");
}
@Test
public void testDeepNesting() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
for (int i = 0; i < 20; i++) {
jsonWriter.beginTag("a");
}
for (int i = 0; i < 20; i++) {
jsonWriter.endTag();
}
assertThat(stringWriter.toString())
.isEqualTo(
"<a><a><a><a><a><a><a><a><a><a>"
+ "<a><a><a><a><a><a><a><a><a>"
+ "<a/>"
+ "</a></a></a></a></a></a></a></a></a>"
+ "</a></a></a></a></a></a></a></a></a></a>");
}
@Test
public void testRepeatedName() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
jsonWriter.beginTag("tag");
jsonWriter.attributeName("a");
jsonWriter.attributeValue("true");
jsonWriter.attributeName("a");
jsonWriter.attributeValue("false");
jsonWriter.beginTag("a").text("true").endTag();
jsonWriter.beginTag("a").text("false").endTag();
jsonWriter.endTag();
// NativeXmlWriter doesn't attempt to detect duplicate names
assertThat(stringWriter.toString()).isEqualTo("<tag a=\"true\" a=\"false\"><a>true</a><a>false</a></tag>");
}
@Test
public void testPrettyPrintArray() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter jsonWriter = new NativeXmlWriter(stringWriter);
jsonWriter.setIndent(" ");
jsonWriter.beginTag("tag");
jsonWriter.attribute("a", "b");
jsonWriter.text("true");
jsonWriter.text("false");
jsonWriter.text("5.0");
jsonWriter.text(null);
jsonWriter.beginTag("object");
jsonWriter.attributeName("a").attributeValue("6.0");
jsonWriter.beginTag("b").text("7.0").endTag();
jsonWriter.endTag();
jsonWriter.beginTag("tag");
jsonWriter.text("8.0");
jsonWriter.text("9.0");
jsonWriter.endTag();
jsonWriter.endTag();
String expected =
"""
<tag a="b">
true
<!---->
false
<!---->
5.0
<!---->
null
<object a="6.0">
<b>7.0</b>
</object>
<tag>
8.0
<!---->
9.0
</tag>
</tag>""";
assertThat(stringWriter.toString()).isEqualTo(expected);
}
@Test
public void testClosedWriterThrowsOnStructure() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter writer = new NativeXmlWriter(stringWriter);
writer.beginTag("tag");
writer.endTag();
writer.close();
try {
writer.beginTag("tag");
fail();
} catch (IllegalStateException expected) {
}
try {
writer.endTag();
fail();
} catch (IllegalStateException expected) {
}
}
@Test
public void testClosedWriterThrowsOnName() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter writer = new NativeXmlWriter(stringWriter);
writer.beginTag("tag");
writer.endTag();
writer.close();
try {
writer.attributeName("a");
fail();
} catch (IllegalStateException expected) {
}
}
@Test
public void testClosedWriterThrowsOnValue() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter writer = new NativeXmlWriter(stringWriter);
writer.beginTag("tag");
writer.endTag();
writer.close();
try {
writer.attributeValue("a");
fail();
} catch (IllegalStateException expected) {
}
}
@Test
public void testClosedWriterThrowsOnFlush() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter writer = new NativeXmlWriter(stringWriter);
writer.beginTag("tag");
writer.endTag();
writer.close();
try {
writer.flush();
fail();
} catch (IllegalStateException expected) {
}
}
@Test
public void testWriterCloseIsIdempotent() throws IOException {
StringWriter stringWriter = new StringWriter();
NativeXmlWriter writer = new NativeXmlWriter(stringWriter);
writer.beginTag("tag");
writer.endTag();
writer.close();
writer.close();
}
}