From c58b806bcfde79754aceff4014b73fd62b028b5a Mon Sep 17 00:00:00 2001 From: Siphalor Date: Sun, 3 Aug 2025 18:42:41 +0200 Subject: [PATCH] [serde-jackson] Support Jackson readers and writers --- ...siphalor.tweed5.shadow.explicit.gradle.kts | 14 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 1 + tweed5-serde-jackson/build.gradle.kts | 15 + tweed5-serde-jackson/gradle.properties | 2 + .../tweed5/data/jackson/JacksonReader.java | 265 ++++++++++++++++++ .../tweed5/data/jackson/JacksonWriter.java | 229 +++++++++++++++ .../tweed5/data/jackson/package-info.java | 4 + .../data/jackson/JacksonReaderTest.java | 78 ++++++ .../data/jackson/JacksonWriterTest.java | 77 +++++ 10 files changed, 687 insertions(+) create mode 100644 buildSrc/src/main/kotlin/de.siphalor.tweed5.shadow.explicit.gradle.kts create mode 100644 tweed5-serde-jackson/build.gradle.kts create mode 100644 tweed5-serde-jackson/gradle.properties create mode 100644 tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/JacksonReader.java create mode 100644 tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/JacksonWriter.java create mode 100644 tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/package-info.java create mode 100644 tweed5-serde-jackson/src/test/java/de/siphalor/tweed5/data/jackson/JacksonReaderTest.java create mode 100644 tweed5-serde-jackson/src/test/java/de/siphalor/tweed5/data/jackson/JacksonWriterTest.java diff --git a/buildSrc/src/main/kotlin/de.siphalor.tweed5.shadow.explicit.gradle.kts b/buildSrc/src/main/kotlin/de.siphalor.tweed5.shadow.explicit.gradle.kts new file mode 100644 index 0000000..c4c3576 --- /dev/null +++ b/buildSrc/src/main/kotlin/de.siphalor.tweed5.shadow.explicit.gradle.kts @@ -0,0 +1,14 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + alias(libs.plugins.shadow) +} + +val shadowOnlyConfiguration = configurations.dependencyScope("shadowOnly") +val shadowOnlyElementsConfiguration = configurations.resolvable("shadowOnlyElements") { + extendsFrom(shadowOnlyConfiguration.get()) +} + +tasks.named("shadowJar") { + configurations = listOf(shadowOnlyElementsConfiguration.get()) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e735e81..ab1bd54 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ assertj = "3.26.3" asm = "9.7" autoservice = "1.1.1" acl = "1.3.5" +jackson = "2.19.0" java-main = "8" java-test = "21" jetbrains-annotations = "26.0.1" @@ -25,6 +26,7 @@ asm-commons = { group = "org.ow2.asm", name = "asm-commons", version.ref = "asm" asm-core = { group = "org.ow2.asm", name = "asm", version.ref = "asm" } autoservice-annotations = { group = "com.google.auto.service", name = "auto-service-annotations", version.ref = "autoservice" } autoservice-processor = { group = "com.google.auto.service", name = "auto-service", version.ref = "autoservice" } +jackson-core = { group = "com.fasterxml.jackson.core", name = "jackson-core", version.ref = "jackson" } jetbrains-annotations = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annotations" } jspecify-annotations = { group = "org.jspecify", name = "jspecify", version.ref = "jspecify" } junit-platform = { group = "org.junit", name = "junit-bom", version.ref = "junit" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6f46cc0..32f166d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,7 @@ include("tweed5-patchwork") include("tweed5-serde-api") include("tweed5-serde-extension") include("tweed5-serde-hjson") +include("tweed5-serde-jackson") include("tweed5-type-utils") include("tweed5-utils") include("tweed5-weaver-pojo") diff --git a/tweed5-serde-jackson/build.gradle.kts b/tweed5-serde-jackson/build.gradle.kts new file mode 100644 index 0000000..3d104fa --- /dev/null +++ b/tweed5-serde-jackson/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("de.siphalor.tweed5.base-module") + id("de.siphalor.tweed5.minecraft.mod.dummy") + id("de.siphalor.tweed5.shadow.explicit") +} + +dependencies { + implementation(project(":tweed5-serde-api")) + implementation(libs.jackson.core) + shadowOnly(libs.jackson.core) +} + +tasks.shadowJar { + relocate("com.fasterxml.jackson.core", "de.siphalor.tweed5.data.jackson.shadowed.jackson.core") +} diff --git a/tweed5-serde-jackson/gradle.properties b/tweed5-serde-jackson/gradle.properties new file mode 100644 index 0000000..2ba9272 --- /dev/null +++ b/tweed5-serde-jackson/gradle.properties @@ -0,0 +1,2 @@ +minecraft.mod.name = Tweed 5 Jackson +minecraft.mod.description = Tweed 5 module that adds support for reading and writing using the Jackson library. diff --git a/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/JacksonReader.java b/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/JacksonReader.java new file mode 100644 index 0000000..0c277db --- /dev/null +++ b/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/JacksonReader.java @@ -0,0 +1,265 @@ +package de.siphalor.tweed5.data.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import de.siphalor.tweed5.dataapi.api.TweedDataReadException; +import de.siphalor.tweed5.dataapi.api.TweedDataReader; +import de.siphalor.tweed5.dataapi.api.TweedDataToken; +import de.siphalor.tweed5.dataapi.api.TweedDataTokens; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Deque; + +public class JacksonReader implements TweedDataReader { + private final JsonParser parser; + private final Deque contextStack = new ArrayDeque<>(); + + private @Nullable TweedDataToken peekedToken; + + public JacksonReader(JsonParser parser) { + this.parser = parser; + this.contextStack.push(Context.VALUE); + } + + @Override + public TweedDataToken peekToken() throws TweedDataReadException { + if (peekedToken == null) { + peekedToken = nextToken(); + } + return peekedToken; + } + + @Override + public TweedDataToken readToken() throws TweedDataReadException { + if (peekedToken != null) { + TweedDataToken token = peekedToken; + peekedToken = null; + return token; + } + return nextToken(); + } + + private TweedDataToken nextToken() throws TweedDataReadException { + try { + JsonToken jsonToken = parser.nextToken(); + switch (jsonToken) { + case START_ARRAY: { + TweedDataToken token = wrapToken(TweedDataTokens.getListStart()); + contextStack.push(Context.LIST); + return token; + } + case END_ARRAY: { + popContext(Context.LIST); + TweedDataToken token = wrapToken(TweedDataTokens.getListEnd()); + afterValueRead(); + return token; + } + case START_OBJECT: { + TweedDataToken token = wrapToken(TweedDataTokens.getMapStart()); + contextStack.push(Context.MAP); + return token; + } + case END_OBJECT: { + popContext(Context.MAP); + TweedDataToken token = wrapToken(TweedDataTokens.getMapEnd()); + afterValueRead(); + return token; + } + case FIELD_NAME: + contextStack.push(Context.MAP_ENTRY_VALUE); + return TweedDataTokens.asMapEntryKey(createStringToken(parser.getText())); + case VALUE_NULL: { + TweedDataToken token = wrapToken(TweedDataTokens.getNull()); + afterValueRead(); + return token; + } + case VALUE_TRUE: { + TweedDataToken token = wrapToken(createBooleanToken(true)); + afterValueRead(); + return token; + } + case VALUE_FALSE: { + TweedDataToken token = wrapToken(createBooleanToken(false)); + afterValueRead(); + return token; + } + case VALUE_NUMBER_INT: { + long longValue = parser.getLongValue(); + TweedDataToken token = wrapToken(new TweedDataToken() { + @Override + public boolean canReadAsByte() { + return longValue >= Byte.MIN_VALUE && longValue <= Byte.MAX_VALUE; + } + + @Override + public byte readAsByte() { + return (byte) longValue; + } + + @Override + public boolean canReadAsShort() { + return longValue >= Short.MIN_VALUE && longValue <= Short.MAX_VALUE; + } + + @Override + public short readAsShort() { + return (short) longValue; + } + + @Override + public boolean canReadAsInt() { + return longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE; + } + + @Override + public int readAsInt() { + return (int) longValue; + } + + @Override + public boolean canReadAsLong() { + return true; + } + + @Override + public long readAsLong() { + return longValue; + } + }); + afterValueRead(); + return token; + } + case VALUE_NUMBER_FLOAT: { + float floatValue = parser.getFloatValue(); + double doubleValue = parser.getDoubleValue(); + TweedDataToken token = wrapToken(new TweedDataToken() { + @Override + public boolean canReadAsFloat() { + return true; + } + + @Override + public float readAsFloat() { + return floatValue; + } + + @Override + public boolean canReadAsDouble() { + return true; + } + + @Override + public double readAsDouble() { + return doubleValue; + } + }); + afterValueRead(); + return token; + } + case VALUE_STRING: { + TweedDataToken token = wrapToken(createStringToken(parser.getText())); + afterValueRead(); + return token; + } + case VALUE_EMBEDDED_OBJECT: + throw TweedDataReadException.builder() + .message("Encountered unsupported embedded object at " + parser.currentLocation()) + .build(); + case NOT_AVAILABLE: + throw TweedDataReadException.builder() + .message("Encountered unexpected NOT_AVAILABLE token at " + parser.currentLocation()) + .build(); + default: + throw TweedDataReadException.builder() + .message("Encountered unexpected token " + jsonToken + " at " + parser.currentLocation()) + .build(); + } + } catch (IOException e) { + throw TweedDataReadException.builder() + .message("Error reading data using jackson at " + parser.currentLocation()) + .cause(e) + .build(); + } + } + + private TweedDataToken wrapToken(TweedDataToken token) throws TweedDataReadException { + Context context = peekContext(); + switch (context) { + case LIST: + return TweedDataTokens.asListValue(token); + case MAP_ENTRY_VALUE: + return TweedDataTokens.asMapEntryValue(token); + case VALUE: + return token; + default: + throw TweedDataReadException.builder() + .message("Encountered token " + token + " in unexpected context: " + context) + .build(); + } + } + + private void afterValueRead() throws TweedDataReadException { + Context context = peekContext(); + switch (context) { + case MAP_ENTRY_VALUE: + case VALUE: + contextStack.pop(); + } + } + + private Context peekContext() throws TweedDataReadException { + Context context = contextStack.peek(); + if (context == null) { + throw TweedDataReadException.builder() + .message("Tried to read context but currently not in any context") + .build(); + } + return context; + } + + private void popContext(Context expectedContext) throws TweedDataReadException { + Context context = contextStack.pop(); + if (context != expectedContext) { + throw TweedDataReadException.builder() + .message("Unexpected context " + context + " when popping " + expectedContext) + .build(); + } + } + + private TweedDataToken createStringToken(String value) { + return new TweedDataToken() { + @Override + public boolean canReadAsString() { + return true; + } + + @Override + public String readAsString() { + return value; + } + }; + } + + private TweedDataToken createBooleanToken(boolean value) { + return new TweedDataToken() { + @Override + public boolean canReadAsBoolean() { + return true; + } + + @Override + public boolean readAsBoolean() { + return value; + } + }; + } + + private enum Context { + VALUE, + LIST, + MAP, + MAP_ENTRY_VALUE, + } +} diff --git a/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/JacksonWriter.java b/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/JacksonWriter.java new file mode 100644 index 0000000..f045738 --- /dev/null +++ b/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/JacksonWriter.java @@ -0,0 +1,229 @@ +package de.siphalor.tweed5.data.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import de.siphalor.tweed5.dataapi.api.TweedDataVisitor; +import de.siphalor.tweed5.dataapi.api.TweedDataWriteException; +import de.siphalor.tweed5.dataapi.api.decoration.TweedDataCommentDecoration; +import de.siphalor.tweed5.dataapi.api.decoration.TweedDataDecoration; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Deque; + +public class JacksonWriter implements TweedDataVisitor { + private final JsonGenerator generator; + private final CommentWriteMode commentWriteMode; + + private final Deque contextStack = new ArrayDeque<>(); + private @Nullable String deferredFieldComment; + + public JacksonWriter(JsonGenerator generator, CommentWriteMode commentWriteMode) { + this.generator = generator; + this.commentWriteMode = commentWriteMode; + this.contextStack.push(Context.VALUE); + } + + @Override + public void visitNull() { + try { + generator.writeNull(); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitBoolean(boolean value) { + try { + generator.writeBoolean(value); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitByte(byte value) { + try { + generator.writeNumber(value); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitShort(short value) { + try { + generator.writeNumber(value); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitInt(int value) { + try { + generator.writeNumber(value); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitLong(long value) { + try { + generator.writeNumber(value); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitFloat(float value) { + try { + generator.writeNumber(value); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitDouble(double value) { + try { + generator.writeNumber(value); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitString(String value) { + try { + generator.writeString(value); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitListStart() { + try { + generator.writeStartArray(); + contextStack.push(Context.LIST); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitListEnd() { + try { + generator.writeEndArray(); + popContext(Context.LIST); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitMapStart() { + try { + generator.writeStartObject(); + contextStack.push(Context.MAP); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitMapEntryKey(String key) { + try { + if (deferredFieldComment != null) { + generator.writeFieldName(key + "__comment"); + generator.writeString(deferredFieldComment); + deferredFieldComment = null; + } + generator.writeFieldName(key); + contextStack.push(Context.VALUE); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitMapEnd() { + try { + generator.writeEndObject(); + popContext(Context.MAP); + afterValueVisited(); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + + @Override + public void visitDecoration(TweedDataDecoration decoration) { + if (decoration instanceof TweedDataCommentDecoration) { + switch (commentWriteMode) { + case NONE: + break; + case MAP_ENTRIES: + if (contextStack.peek() == Context.MAP) { + if (deferredFieldComment == null) { + deferredFieldComment = ((TweedDataCommentDecoration) decoration).comment(); + } else { + deferredFieldComment += "\n" + ((TweedDataCommentDecoration) decoration).comment(); + } + } + break; + case DOUBLE_SLASHES: + try { + generator.writeRaw("// "); + generator.writeRaw(((TweedDataCommentDecoration) decoration).comment()); + generator.writeRaw("\n"); + } catch (IOException e) { + throw createWriteExceptionForIOException(e); + } + } + } + } + + private void afterValueVisited() { + if (contextStack.peek() == Context.VALUE) { + contextStack.pop(); + } + } + + private void popContext(Context expectedContext) { + Context context = contextStack.pop(); + if (context != expectedContext) { + throw new IllegalStateException("Unexpected context " + context + " when popping " + expectedContext); + } + } + + private TweedDataWriteException createWriteExceptionForIOException(IOException e) { + throw new TweedDataWriteException("Error writing data using jackson at " + generator.getOutputContext(), e); + } + + public enum CommentWriteMode { + NONE, + MAP_ENTRIES, + DOUBLE_SLASHES, + } + + private enum Context { + VALUE, + LIST, + MAP, + } +} diff --git a/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/package-info.java b/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/package-info.java new file mode 100644 index 0000000..b6bfd12 --- /dev/null +++ b/tweed5-serde-jackson/src/main/java/de/siphalor/tweed5/data/jackson/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package de.siphalor.tweed5.data.jackson; + +import org.jspecify.annotations.NullMarked; diff --git a/tweed5-serde-jackson/src/test/java/de/siphalor/tweed5/data/jackson/JacksonReaderTest.java b/tweed5-serde-jackson/src/test/java/de/siphalor/tweed5/data/jackson/JacksonReaderTest.java new file mode 100644 index 0000000..bb3588f --- /dev/null +++ b/tweed5-serde-jackson/src/test/java/de/siphalor/tweed5/data/jackson/JacksonReaderTest.java @@ -0,0 +1,78 @@ +package de.siphalor.tweed5.data.jackson; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadFeature; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class JacksonReaderTest { + @SneakyThrows + @Test + void complex() { + var inputStream = new ByteArrayInputStream(""" + { + "first": [ + [ 1 ] + ], + "second": { + "test": "Hello World!" + } + } + """.getBytes(StandardCharsets.UTF_8)); + try (var parser = JsonFactory.builder().enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION).build().createParser(inputStream)) { + var reader = new JacksonReader(parser); + + var token = reader.peekToken(); + assertThat(token.isMapStart()).isTrue(); + token = reader.readToken(); + assertThat(token.isMapStart()).isTrue(); + token = reader.readToken(); + assertThat(token.isMapEntryKey()).isTrue(); + assertThat(token.canReadAsString()).isTrue(); + assertThat(token.readAsString()).isEqualTo("first"); + token = reader.readToken(); + assertThat(token.isMapEntryValue()).isTrue(); + assertThat(token.isListStart()).isTrue(); + token = reader.readToken(); + assertThat(token.isMapEntryValue()).isFalse(); + assertThat(token.isListValue()).isTrue(); + assertThat(token.isListStart()).isTrue(); + token = reader.readToken(); + assertThat(token.isListValue()).isTrue(); + assertThat(token.canReadAsInt()).isTrue(); + assertThat(token.readAsInt()).isEqualTo(1); + token = reader.readToken(); + assertThat(token.isListValue()).isTrue(); + assertThat(token.isListEnd()).isTrue(); + token = reader.readToken(); + assertThat(token.isListValue()).isFalse(); + assertThat(token.isMapEntryValue()).isTrue(); + assertThat(token.isListEnd()).isTrue(); + token = reader.readToken(); + assertThat(token.isMapEntryKey()).isTrue(); + assertThat(token.canReadAsString()).isTrue(); + assertThat(token.readAsString()).isEqualTo("second"); + token = reader.readToken(); + assertThat(token.isMapEntryValue()).isTrue(); + assertThat(token.isMapStart()).isTrue(); + token = reader.readToken(); + assertThat(token.isMapEntryValue()).isFalse(); + assertThat(token.isMapEntryKey()).isTrue(); + assertThat(token.canReadAsString()).isTrue(); + assertThat(token.readAsString()).isEqualTo("test"); + token = reader.readToken(); + assertThat(token.isMapEntryValue()).isTrue(); + assertThat(token.canReadAsString()).isTrue(); + assertThat(token.readAsString()).isEqualTo("Hello World!"); + token = reader.readToken(); + assertThat(token.isMapEnd()).isTrue(); + token = reader.readToken(); + assertThat(token.isMapEnd()).isTrue(); + } + } +} diff --git a/tweed5-serde-jackson/src/test/java/de/siphalor/tweed5/data/jackson/JacksonWriterTest.java b/tweed5-serde-jackson/src/test/java/de/siphalor/tweed5/data/jackson/JacksonWriterTest.java new file mode 100644 index 0000000..e5e97cd --- /dev/null +++ b/tweed5-serde-jackson/src/test/java/de/siphalor/tweed5/data/jackson/JacksonWriterTest.java @@ -0,0 +1,77 @@ +package de.siphalor.tweed5.data.jackson; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.core.util.Separators; +import de.siphalor.tweed5.dataapi.api.decoration.TweedDataCommentDecoration; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; + +import static org.assertj.core.api.Assertions.assertThat; + +class JacksonWriterTest { + @SneakyThrows + @Test + void object() { + var stringWriter = new StringWriter(); + try (var generator = JsonFactory.builder().build().createGenerator(stringWriter)) { + generator.setPrettyPrinter( + new DefaultPrettyPrinter() + .withSeparators(new Separators().withObjectFieldValueSpacing(Separators.Spacing.AFTER)) + ); + var writer = new JacksonWriter(generator, JacksonWriter.CommentWriteMode.MAP_ENTRIES); + writer.visitMapStart(); + writer.visitDecoration((TweedDataCommentDecoration) () -> "Hello\nWorld"); + writer.visitDecoration((TweedDataCommentDecoration) () -> "!"); + writer.visitMapEntryKey("test"); + writer.visitInt(1234); + writer.visitMapEnd(); + } + + assertThat(stringWriter.toString()).isEqualTo(""" + { + "test__comment": "Hello\\nWorld\\n!", + "test": 1234 + }"""); + } + + @SneakyThrows + @Test + void complex() { + var stringWriter = new StringWriter(); + try (var generator = JsonFactory.builder().build().createGenerator(stringWriter)) { + generator.setPrettyPrinter( + new DefaultPrettyPrinter() + .withSeparators(new Separators().withObjectFieldValueSpacing(Separators.Spacing.AFTER)) + ); + var writer = new JacksonWriter(generator, JacksonWriter.CommentWriteMode.MAP_ENTRIES); + writer.visitMapStart(); + writer.visitMapEntryKey("first"); + writer.visitListStart(); + writer.visitInt(1); + writer.visitDecoration((TweedDataCommentDecoration) () -> "not written"); + writer.visitInt(2); + writer.visitListEnd(); + writer.visitDecoration((TweedDataCommentDecoration) () -> "second object"); + writer.visitMapEntryKey("second"); + writer.visitMapStart(); + writer.visitDecoration((TweedDataCommentDecoration) () -> "inner entry"); + writer.visitMapEntryKey("inner"); + writer.visitBoolean(true); + writer.visitMapEnd(); + writer.visitMapEnd(); + } + + assertThat(stringWriter.toString()).isEqualTo(""" + { + "first": [ 1, 2 ], + "second__comment": "second object", + "second": { + "inner__comment": "inner entry", + "inner": true + } + }"""); + } +}