diff --git a/build.gradle b/build.gradle
index ed82cd5..eff703f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -51,8 +51,8 @@ pitest {
spotbugs {
toolVersion = '4.9.8'
excludeFilter = file('spotbugs-exclude.xml')
- effort = "max"
- reportLevel = "low"
+ effort = com.github.spotbugs.snom.Effort.valueOf("MAX")
+ reportLevel = com.github.spotbugs.snom.Confidence.valueOf("LOW")
reportsDir = layout.buildDirectory.dir('spotbugs')
}
@@ -84,7 +84,7 @@ tasks.spotbugsTest {
}
pmd {
- toolVersion = '7.0.0'
+ toolVersion = '7.21.0'
ruleSetFiles = files('pmd-rules.xml')
ruleSets = [] // Disable default rulesets, use custom file only
consoleOutput = true
@@ -128,7 +128,7 @@ tasks.checkstyleTest {
dependencies {
implementation 'tools.jackson.core:jackson-databind:3.0.4'
- implementation 'tools.jackson.module:jackson-module-afterburner:3.0.4'
+ implementation 'tools.jackson.module:jackson-module-blackbird:3.0.4'
compileOnly 'com.github.spotbugs:spotbugs-annotations:4.9.8'
testImplementation platform('org.junit:junit-bom:6.0.2')
testImplementation 'org.junit.jupiter:junit-jupiter'
@@ -140,12 +140,26 @@ dependencies {
testAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
}
+// Check if running in CI or coverage is explicitly requested
+boolean isCi = System.getenv('CI') == 'true'
+boolean withCoverage = project.hasProperty('withCoverage') || isCi
+
test {
useJUnitPlatform()
- finalizedBy jacocoTestReport // report is always generated after tests run
+ // Only run jacoco coverage in CI or when explicitly requested
+ if (withCoverage) {
+ finalizedBy jacocoTestReport
+ jacoco {
+ enabled = true
+ }
+ } else {
+ // Completely disable jacoco for local development
+ jacoco {
+ enabled = false
+ }
+ }
}
-
jacocoTestReport {
dependsOn test
reports {
@@ -156,6 +170,8 @@ jacocoTestReport {
}
jacocoTestCoverageVerification {
+ // Only enforce coverage thresholds in CI
+ onlyIf { isCi }
violationRules {
rule {
enabled = true
@@ -174,6 +190,11 @@ jacocoTestCoverageVerification {
}
}
}
+
+// Only add coverage verification to check task in CI
+if (isCi) {
+ check.dependsOn jacocoTestCoverageVerification
+}
check.dependsOn jacocoTestReport
check.dependsOn spotbugsMain
check.dependsOn pmdMain
@@ -203,7 +224,7 @@ tasks.register('jmh', JavaExec) {
group = 'verification'
description = 'Run JMH benchmarks'
classpath = configurations.testRuntimeClasspath + sourceSets.test.runtimeClasspath
- mainClass = 'dev.toonformat.jtoon.JToonBenchmark'
+ mainClass = findProperty('benchmarkClass') ?: 'dev.toonformat.jtoon.JToonBenchmark'
workingDir = projectDir
doFirst {
file('build/jmh-results').mkdirs()
diff --git a/pmd-rules-test.xml b/pmd-rules-test.xml
index 2811908..0935b71 100644
--- a/pmd-rules-test.xml
+++ b/pmd-rules-test.xml
@@ -11,13 +11,15 @@
-
-
-
+
+
+
+
+
@@ -28,6 +30,7 @@
+
@@ -70,6 +73,7 @@
+
@@ -81,6 +85,7 @@
+
@@ -102,6 +107,8 @@
+
+
@@ -120,6 +127,7 @@
+
diff --git a/pmd-rules.xml b/pmd-rules.xml
index 4c41fed..b5d8556 100644
--- a/pmd-rules.xml
+++ b/pmd-rules.xml
@@ -11,9 +11,8 @@
-
-
-
+
+
@@ -40,13 +39,13 @@
-
+
@@ -64,6 +63,7 @@
+
diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml
index b9311e6..5019218 100644
--- a/spotbugs-exclude.xml
+++ b/spotbugs-exclude.xml
@@ -11,6 +11,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/dev/toonformat/jtoon/JToon.java b/src/main/java/dev/toonformat/jtoon/JToon.java
index 250f730..d0c2157 100644
--- a/src/main/java/dev/toonformat/jtoon/JToon.java
+++ b/src/main/java/dev/toonformat/jtoon/JToon.java
@@ -4,6 +4,7 @@
import dev.toonformat.jtoon.encoder.ValueEncoder;
import dev.toonformat.jtoon.normalizer.JsonNormalizer;
import tools.jackson.databind.JsonNode;
+import java.util.Objects;
/**
* Main entry point for encoding and decoding TOON (Token-Oriented Object Notation) format.
@@ -41,8 +42,10 @@ public static String encode(final Object input) {
* @param input The object to encode (can be null)
* @param options Encoding options (indent, delimiter, length marker)
* @return The JToon-formatted string
+ * @throws NullPointerException if options is null
*/
public static String encode(final Object input, final EncodeOptions options) {
+ Objects.requireNonNull(options, "EncodeOptions cannot be null");
final JsonNode normalizedValue = JsonNormalizer.normalize(input);
return ValueEncoder.encodeValue(normalizedValue, options);
}
@@ -68,17 +71,17 @@ public static String encodeJson(final String json) {
* Encodes a plain JSON string to TOON format using custom options.
*
*
- * Parsing is delegated to
- * {@link JsonNormalizer#parse(String)}
- * to maintain separation of concerns.
+ * Parses the JSON string to a tree structure, then encodes to TOON format.
*
*
* @param json The JSON string to encode (must be valid JSON)
* @param options Encoding options (indent, delimiter, length marker)
* @return The TOON-formatted string
* @throws IllegalArgumentException if the input is not valid JSON
+ * @throws NullPointerException if options is null
*/
public static String encodeJson(final String json, final EncodeOptions options) {
+ Objects.requireNonNull(options, "EncodeOptions cannot be null");
final JsonNode parsed = JsonNormalizer.parse(json);
return ValueEncoder.encodeValue(parsed, options);
}
@@ -113,8 +116,10 @@ public static Object decode(final String toon) {
* @return Parsed object (Map, List, primitive, or null)
* @throws IllegalArgumentException if strict mode is enabled and input is
* invalid
+ * @throws NullPointerException if options is null
*/
public static Object decode(final String toon, final DecodeOptions options) {
+ Objects.requireNonNull(options, "DecodeOptions cannot be null");
return ValueDecoder.decode(toon, options);
}
@@ -150,8 +155,10 @@ public static String decodeToJson(final String toon) {
* @return JSON string representation
* @throws IllegalArgumentException if strict mode is enabled and input is
* invalid
+ * @throws NullPointerException if options is null
*/
public static String decodeToJson(final String toon, final DecodeOptions options) {
+ Objects.requireNonNull(options, "DecodeOptions cannot be null");
return ValueDecoder.decodeToJson(toon, options);
}
}
diff --git a/src/main/java/dev/toonformat/jtoon/encoder/ArrayEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/ArrayEncoder.java
index d562d46..bd27a95 100644
--- a/src/main/java/dev/toonformat/jtoon/encoder/ArrayEncoder.java
+++ b/src/main/java/dev/toonformat/jtoon/encoder/ArrayEncoder.java
@@ -4,9 +4,7 @@
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
-import java.util.ArrayList;
import java.util.List;
-import java.util.stream.StreamSupport;
import static dev.toonformat.jtoon.util.Constants.LIST_ITEM_PREFIX;
import static dev.toonformat.jtoon.util.Constants.SPACE;
@@ -39,26 +37,47 @@ public static void encodeArray(final String key, final ArrayNode value,
return;
}
- // Primitive array
- if (isArrayOfPrimitives(value)) {
+ final int size = value.size();
+ boolean allPrimitives = true;
+ boolean allArrays = true;
+ boolean allObjects = true;
+
+ for (int i = 0; i < size; i++) {
+ final JsonNode item = value.get(i);
+ if (!item.isValueNode()) {
+ allPrimitives = false;
+ }
+ if (!item.isArray()) {
+ allArrays = false;
+ }
+ if (!item.isObject()) {
+ allObjects = false;
+ }
+ if (!allPrimitives && !allArrays && !allObjects) {
+ break;
+ }
+ }
+
+ if (allPrimitives) {
encodeInlinePrimitiveArray(key, value, writer, depth, options);
return;
}
- // Array of arrays (all primitives)
- if (isArrayOfArrays(value)) {
- final boolean allPrimitiveArrays = StreamSupport.stream(value.spliterator(), false)
- .filter(JsonNode::isArray)
- .allMatch(ArrayEncoder::isArrayOfPrimitives);
-
+ if (allArrays) {
+ boolean allPrimitiveArrays = true;
+ for (int i = 0; i < size; i++) {
+ if (!isArrayOfPrimitives(value.get(i))) {
+ allPrimitiveArrays = false;
+ break;
+ }
+ }
if (allPrimitiveArrays) {
encodeArrayOfArraysAsListItems(key, value, writer, depth, options);
return;
}
}
- // Array of objects
- if (isArrayOfObjects(value)) {
+ if (allObjects) {
final List header = TabularArrayEncoder.detectTabularHeader(value);
if (!header.isEmpty()) {
TabularArrayEncoder.encodeArrayOfObjectsAsTabular(key, value, header, writer, depth, options);
@@ -68,7 +87,6 @@ public static void encodeArray(final String key, final ArrayNode value,
return;
}
- // Mixed array: fallback to expanded format
encodeMixedArrayAsListItems(key, value, writer, depth, options);
}
@@ -147,17 +165,25 @@ private static void encodeInlinePrimitiveArray(final String prefix, final ArrayN
*/
public static String formatInlineArray(final ArrayNode values, final String delimiter,
final String prefix, final boolean lengthMarker) {
- final List valueList = new ArrayList<>();
- values.forEach(valueList::add);
-
final String header = PrimitiveEncoder.formatHeader(values.size(), prefix, null, delimiter, lengthMarker);
- final String joinedValue = PrimitiveEncoder.joinEncodedValues(valueList, delimiter);
- // Only add space if there are values
+ // Early return for empty arrays
if (values.isEmpty()) {
return header;
}
- return header + SPACE + joinedValue;
+
+ // Build joined values directly without intermediate collection
+ final StringBuilder joinedValues = new StringBuilder(128);
+ boolean first = true;
+ for (final JsonNode value : values) {
+ if (!first) {
+ joinedValues.append(delimiter);
+ }
+ first = false;
+ joinedValues.append(PrimitiveEncoder.encodePrimitive(value, delimiter));
+ }
+
+ return header + SPACE + joinedValues;
}
/**
diff --git a/src/main/java/dev/toonformat/jtoon/encoder/Flatten.java b/src/main/java/dev/toonformat/jtoon/encoder/Flatten.java
index 3126c0c..aa16f13 100644
--- a/src/main/java/dev/toonformat/jtoon/encoder/Flatten.java
+++ b/src/main/java/dev/toonformat/jtoon/encoder/Flatten.java
@@ -42,7 +42,7 @@ public record FoldResult(String foldedKey,
* @param tail the tail node (if any)
* @param leafValue the leaf JsonValue
*/
- private record ChainResult(List segments, JsonNode tail, JsonNode leafValue) {
+ record ChainResult(List segments, JsonNode tail, JsonNode leafValue) {
}
/**
@@ -136,7 +136,7 @@ public static FoldResult tryFoldKeyChain(final String key,
* @param maxDepth maximum number of allowed segments
* @return a {@link ChainResult} containing segments, tail, and leafValue
*/
- private static ChainResult collectSingleKeyChain(final String startKey,
+ static ChainResult collectSingleKeyChain(final String startKey,
final JsonNode startValue,
final int maxDepth) {
// normalize absolute key to its local segment
diff --git a/src/main/java/dev/toonformat/jtoon/encoder/HeaderFormatter.java b/src/main/java/dev/toonformat/jtoon/encoder/HeaderFormatter.java
index 2baec22..403c28b 100644
--- a/src/main/java/dev/toonformat/jtoon/encoder/HeaderFormatter.java
+++ b/src/main/java/dev/toonformat/jtoon/encoder/HeaderFormatter.java
@@ -115,10 +115,20 @@ private static void appendFieldsIfPresent(
}
private static String formatFields(final Collection fields, final String delimiter) {
- return fields.stream()
- .map(PrimitiveEncoder::encodeKey)
- .reduce((a, b) -> a + delimiter + b)
- .orElse("");
+ if (fields.isEmpty()) {
+ return "";
+ }
+
+ final StringBuilder sb = new StringBuilder();
+ boolean first = true;
+ for (final String field : fields) {
+ if (!first) {
+ sb.append(delimiter);
+ }
+ first = false;
+ sb.append(PrimitiveEncoder.encodeKey(field));
+ }
+ return sb.toString();
}
}
diff --git a/src/main/java/dev/toonformat/jtoon/encoder/LineWriter.java b/src/main/java/dev/toonformat/jtoon/encoder/LineWriter.java
index 8ab1631..1d22e02 100644
--- a/src/main/java/dev/toonformat/jtoon/encoder/LineWriter.java
+++ b/src/main/java/dev/toonformat/jtoon/encoder/LineWriter.java
@@ -51,7 +51,9 @@ public void push(final int depth, final String content) {
if (depth < indentCache.length) {
stringBuilder.append(indentCache[depth]);
} else {
- stringBuilder.append(String.valueOf(indentationString).repeat(depth));
+ for (int i = 0; i < depth; i++) {
+ stringBuilder.append(indentationString);
+ }
}
}
stringBuilder.append(content);
diff --git a/src/main/java/dev/toonformat/jtoon/encoder/ObjectEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/ObjectEncoder.java
index 8b9185a..d5d3732 100644
--- a/src/main/java/dev/toonformat/jtoon/encoder/ObjectEncoder.java
+++ b/src/main/java/dev/toonformat/jtoon/encoder/ObjectEncoder.java
@@ -6,10 +6,8 @@
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
import java.util.LinkedHashSet;
-import java.util.List;
import java.util.Map;
import java.util.Set;
-import java.util.stream.Collectors;
import static dev.toonformat.jtoon.util.Constants.DOT;
import static dev.toonformat.jtoon.util.Constants.COLON;
import static dev.toonformat.jtoon.util.Constants.SPACE;
@@ -44,24 +42,27 @@ public static void encodeObject(final ObjectNode value,
final String pathPrefix,
final Integer remainingDepth,
final Set blockedKeys) {
- final List> fields = value.properties().stream().toList();
+ final int effectiveFlattenDepth = remainingDepth != null ? remainingDepth : options.flattenDepth();
- // At root level (depth 0), collect all literal dotted keys for collision checking
+ // Single-pass collection: gather sibling keys and optionally dotted keys at root level
+ final Set siblings = new LinkedHashSet<>();
if (depth == 0 && rootLiteralKeys != null) {
rootLiteralKeys.clear();
- fields.stream()
- .filter(e -> e.getKey().contains(DOT))
- .map(Map.Entry::getKey)
- .forEach(rootLiteralKeys::add);
+ for (final Map.Entry entry : value.properties()) {
+ final String key = entry.getKey();
+ siblings.add(key);
+ if (key.contains(DOT)) {
+ rootLiteralKeys.add(key);
+ }
+ }
+ } else {
+ for (final Map.Entry entry : value.properties()) {
+ siblings.add(entry.getKey());
+ }
}
- final int effectiveFlattenDepth = remainingDepth != null ? remainingDepth : options.flattenDepth();
-
- //the siblings collision do not need the absolute path
- final Set siblings = fields.stream()
- .map(Map.Entry::getKey)
- .collect(Collectors.toCollection(LinkedHashSet::new));
- for (Map.Entry entry : fields) {
+ // Encode each field
+ for (final Map.Entry entry : value.properties()) {
encodeKeyValuePair(entry.getKey(), entry.getValue(), writer, depth, options, siblings, rootLiteralKeys,
pathPrefix, effectiveFlattenDepth, blockedKeys);
}
@@ -104,11 +105,13 @@ public static void encodeKeyValuePair(final String key,
EncodeOptions currentOptions = options;
// Attempt key folding when enabled
+ final KeyFolding flattenMode = currentOptions.flatten();
if (remainingDepth > 0
&& !siblings.isEmpty()
&& blockedKeys != null
&& !blockedKeys.contains(key)
- && KeyFolding.SAFE.equals(currentOptions.flatten())) {
+ && flattenMode != null
+ && flattenMode == KeyFolding.SAFE) {
final Flatten.FoldResult foldResult = Flatten.tryFoldKeyChain(key, value, siblings, rootLiteralKeys,
pathPrefix, remainingDepth);
if (foldResult != null) {
diff --git a/src/main/java/dev/toonformat/jtoon/encoder/TabularArrayEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/TabularArrayEncoder.java
index e4ce900..efae136 100644
--- a/src/main/java/dev/toonformat/jtoon/encoder/TabularArrayEncoder.java
+++ b/src/main/java/dev/toonformat/jtoon/encoder/TabularArrayEncoder.java
@@ -52,11 +52,8 @@ public static List detectTabularHeader(final ArrayNode rows) {
/**
* Checks if all rows in the array have the same keys with primitive values.
*/
- private static boolean isTabularArray(final Iterable rows, final Iterable header) {
- final List headerList = new ArrayList<>();
- for (String h : header) {
- headerList.add(h);
- }
+ private static boolean isTabularArray(final Iterable rows, final List header) {
+ final int headerSize = header.size();
for (JsonNode row : rows) {
if (!row.isObject()) {
@@ -64,19 +61,16 @@ private static boolean isTabularArray(final Iterable rows, final Itera
}
final ObjectNode obj = (ObjectNode) row;
- final List keys = new ArrayList<>(obj.propertyNames());
- // All objects must have the same keys (but order can differ)
- if (keys.size() != headerList.size()) {
+ // All objects must have the same number of keys
+ if (obj.size() != headerSize) {
return false;
}
// Check that all header keys exist in the row and all values are primitives
- for (String key : headerList) {
- if (!obj.has(key)) {
- return false;
- }
- if (!obj.get(key).isValueNode()) {
+ for (final String key : header) {
+ final JsonNode value = obj.get(key);
+ if (value == null || !value.isValueNode()) {
return false;
}
}
@@ -115,27 +109,39 @@ public static void encodeArrayOfObjectsAsTabular(final String prefix, final Arra
* @param depth Indentation depth
* @param options Encoding options
*/
- public static void writeTabularRows(final Iterable rows, final Iterable header,
+ public static void writeTabularRows(final Iterable rows, final List header,
final LineWriter writer, final int depth, final EncodeOptions options) {
- final List headerList = new ArrayList<>();
- for (String h : header) {
- headerList.add(h);
- }
- final int headerSize = headerList.size();
-
for (JsonNode row : rows) {
- //skip non-object rows
+ // Skip non-object rows
if (!row.isObject()) {
continue;
}
final ObjectNode obj = (ObjectNode) row;
- final List values = new ArrayList<>(headerSize);
- for (String key : headerList) {
- values.add(obj.get(key));
- }
- final String joinedValue = PrimitiveEncoder.joinEncodedValues(values, options.delimiter().toString());
+ final String joinedValue = joinRowValues(obj, header, options.delimiter().toString());
writer.push(depth, joinedValue);
}
}
+
+ /**
+ * Joins values from a single row according to header order.
+ * Avoids creating intermediate collections.
+ * Missing keys are skipped.
+ */
+ private static String joinRowValues(final ObjectNode row, final List header, final String delimiter) {
+ final StringBuilder sb = new StringBuilder(128);
+ boolean first = true;
+ for (final String key : header) {
+ final JsonNode value = row.get(key);
+ if (value == null) {
+ continue; // Skip missing keys
+ }
+ if (!first) {
+ sb.append(delimiter);
+ }
+ first = false;
+ sb.append(PrimitiveEncoder.encodePrimitive(value, delimiter));
+ }
+ return sb.toString();
+ }
}
diff --git a/src/main/java/dev/toonformat/jtoon/encoder/ValueEncoder.java b/src/main/java/dev/toonformat/jtoon/encoder/ValueEncoder.java
index 8c00180..2b558f4 100644
--- a/src/main/java/dev/toonformat/jtoon/encoder/ValueEncoder.java
+++ b/src/main/java/dev/toonformat/jtoon/encoder/ValueEncoder.java
@@ -20,11 +20,16 @@ private ValueEncoder() {
/**
* Encodes a normalized JsonNode value to TOON format.
*
- * @param value The JsonNode to encode
+ * @param value The JsonNode to encode (can be null)
* @param options Encoding options (indent, delimiter, length marker)
* @return The TOON-formatted string
*/
public static String encodeValue(final JsonNode value, final EncodeOptions options) {
+ // Handle null values
+ if (value == null || value.isNull()) {
+ return "null";
+ }
+
// Handle primitive values directly
if (value.isValueNode()) {
return PrimitiveEncoder.encodePrimitive(value, options.delimiter().toString());
diff --git a/src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java b/src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java
index 3de03c6..c1cf3d6 100644
--- a/src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java
+++ b/src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java
@@ -32,7 +32,6 @@
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
-import java.util.function.IntFunction;
import java.util.stream.Stream;
/**
@@ -71,13 +70,16 @@ private JsonNormalizer() {
* @throws IllegalArgumentException if the input is blank or not valid JSON
*/
public static JsonNode parse(final String json) {
- if (json == null || json.isBlank()) {
- throw new IllegalArgumentException("Invalid JSON");
+ if (json == null) {
+ throw new IllegalArgumentException("JSON string cannot be null");
+ }
+ if (json.isBlank()) {
+ throw new IllegalArgumentException("JSON string cannot be blank");
}
try {
return MAPPER.readTree(json);
} catch (Exception e) {
- throw new IllegalArgumentException("Invalid JSON", e);
+ throw new IllegalArgumentException("Invalid JSON: " + e.getMessage(), e);
}
}
@@ -260,7 +262,9 @@ private static JsonNode tryNormalizeCollection(final Object value) {
*/
private static ArrayNode normalizeCollection(final Collection> collection) {
final ArrayNode arrayNode = MAPPER.createArrayNode();
- collection.forEach(item -> arrayNode.add(normalize(item)));
+ for (Object item : collection) {
+ arrayNode.add(normalize(item));
+ }
return arrayNode;
}
@@ -269,7 +273,9 @@ private static ArrayNode normalizeCollection(final Collection> collection) {
*/
private static ObjectNode normalizeMap(final Map, ?> map) {
final ObjectNode objectNode = MAPPER.createObjectNode();
- map.forEach((key, value) -> objectNode.set(String.valueOf(key), normalize(value)));
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ objectNode.set(String.valueOf(entry.getKey()), normalize(entry.getValue()));
+ }
return objectNode;
}
@@ -286,58 +292,68 @@ private static JsonNode tryNormalizePojo(final Object value) {
}
/**
- * Normalizes arrays to ArrayNode.
+ * Normalizes primitive arrays to ArrayNode without auto-boxing overhead.
+ * Uses direct array population to avoid IntFunction lambda allocations.
*/
private static JsonNode normalizeArray(final Object array) {
if (array instanceof int[] intArr) {
- return buildArrayNode(intArr.length, i -> IntNode.valueOf(intArr[i]));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < intArr.length; i++) {
+ node.add(IntNode.valueOf(intArr[i]));
+ }
+ return node;
} else if (array instanceof long[] longArr) {
- return buildArrayNode(longArr.length, i -> LongNode.valueOf(longArr[i]));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < longArr.length; i++) {
+ node.add(LongNode.valueOf(longArr[i]));
+ }
+ return node;
} else if (array instanceof double[] doubleArr) {
- return buildArrayNode(doubleArr.length, i -> normalizeDoubleElement(doubleArr[i]));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < doubleArr.length; i++) {
+ final double val = doubleArr[i];
+ node.add(Double.isFinite(val) ? DoubleNode.valueOf(val) : NullNode.getInstance());
+ }
+ return node;
} else if (array instanceof float[] floatArr) {
- return buildArrayNode(floatArr.length, i -> normalizeFloatElement(floatArr[i]));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < floatArr.length; i++) {
+ final float val = floatArr[i];
+ node.add(Float.isFinite(val) ? FloatNode.valueOf(val) : NullNode.getInstance());
+ }
+ return node;
} else if (array instanceof boolean[] boolArr) {
- return buildArrayNode(boolArr.length, i -> BooleanNode.valueOf(boolArr[i]));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < boolArr.length; i++) {
+ node.add(BooleanNode.valueOf(boolArr[i]));
+ }
+ return node;
} else if (array instanceof byte[] byteArr) {
- return buildArrayNode(byteArr.length, i -> IntNode.valueOf(byteArr[i]));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < byteArr.length; i++) {
+ node.add(IntNode.valueOf(byteArr[i]));
+ }
+ return node;
} else if (array instanceof short[] shortArr) {
- return buildArrayNode(shortArr.length, i -> ShortNode.valueOf(shortArr[i]));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < shortArr.length; i++) {
+ node.add(ShortNode.valueOf(shortArr[i]));
+ }
+ return node;
} else if (array instanceof char[] charArr) {
- return buildArrayNode(charArr.length, i -> StringNode.valueOf(String.valueOf(charArr[i])));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < charArr.length; i++) {
+ node.add(StringNode.valueOf(String.valueOf(charArr[i])));
+ }
+ return node;
} else if (array instanceof Object[] objArr) {
- return buildArrayNode(objArr.length, i -> normalize(objArr[i]));
+ final ArrayNode node = MAPPER.createArrayNode();
+ for (int i = 0; i < objArr.length; i++) {
+ node.add(normalize(objArr[i]));
+ }
+ return node;
} else {
return MAPPER.createArrayNode();
}
}
-
- /**
- * Builds an ArrayNode using a functional approach.
- */
- private static ArrayNode buildArrayNode(final int length, final IntFunction mapper) {
- final ArrayNode arrayNode = MAPPER.createArrayNode();
- for (int i = 0; i < length; i++) {
- arrayNode.add(mapper.apply(i));
- }
- return arrayNode;
- }
-
- /**
- * Normalizes a single double element from an array.
- */
- private static JsonNode normalizeDoubleElement(final double value) {
- return Double.isFinite(value)
- ? DoubleNode.valueOf(value)
- : NullNode.getInstance();
- }
-
- /**
- * Normalizes a single float element from an array.
- */
- private static JsonNode normalizeFloatElement(final float value) {
- return Float.isFinite(value)
- ? FloatNode.valueOf(value)
- : NullNode.getInstance();
- }
}
diff --git a/src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java b/src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java
index d7c39bc..3b41338 100644
--- a/src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java
+++ b/src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java
@@ -4,7 +4,7 @@
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
-import tools.jackson.module.afterburner.AfterburnerModule;
+import tools.jackson.module.blackbird.BlackbirdModule;
import java.util.TimeZone;
/**
@@ -31,12 +31,14 @@ public static ObjectMapper getInstance() {
synchronized (ObjectMapperSingleton.class) {
result = instance;
if (result == null) {
- instance = result = JsonMapper.builder()
+ result = JsonMapper.builder()
.changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.ALWAYS))
- .addModule(new AfterburnerModule()) // Speeds up Jackson by 20–40% in most real-world cases
+ .addModule(new BlackbirdModule()) // Speeds up Jackson by 20-40%
+ // using MethodHandles (faster than Afterburner)
.defaultTimeZone(TimeZone.getTimeZone("UTC")) // set a default timezone for dates
.disable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
.build();
+ instance = result;
}
}
}
diff --git a/src/main/java/dev/toonformat/jtoon/util/StringEscaper.java b/src/main/java/dev/toonformat/jtoon/util/StringEscaper.java
index 5f5fce2..20abf4d 100644
--- a/src/main/java/dev/toonformat/jtoon/util/StringEscaper.java
+++ b/src/main/java/dev/toonformat/jtoon/util/StringEscaper.java
@@ -18,12 +18,26 @@ private StringEscaper() {
* @return The escaped string
*/
public static String escape(final String value) {
- return value
- .replace("\\", "\\\\")
- .replace("\"", "\\\"")
- .replace("\n", "\\n")
- .replace("\r", "\\r")
- .replace("\t", "\\t");
+ if (value == null || value.isEmpty()) {
+ return value;
+ }
+
+ final int len = value.length();
+ final StringBuilder sb = new StringBuilder(len + 16);
+
+ for (int i = 0; i < len; i++) {
+ final char c = value.charAt(i);
+ switch (c) {
+ case '\\' -> sb.append("\\\\");
+ case '"' -> sb.append("\\\"");
+ case '\n' -> sb.append("\\n");
+ case '\r' -> sb.append("\\r");
+ case '\t' -> sb.append("\\t");
+ default -> sb.append(c);
+ }
+ }
+
+ return sb.toString();
}
/**
diff --git a/src/main/java/dev/toonformat/jtoon/util/StringValidator.java b/src/main/java/dev/toonformat/jtoon/util/StringValidator.java
index 65d6b3b..935bed5 100644
--- a/src/main/java/dev/toonformat/jtoon/util/StringValidator.java
+++ b/src/main/java/dev/toonformat/jtoon/util/StringValidator.java
@@ -1,8 +1,6 @@
package dev.toonformat.jtoon.util;
-import java.util.regex.Pattern;
import static dev.toonformat.jtoon.util.Constants.BACKSLASH;
-import static dev.toonformat.jtoon.util.Constants.COLON;
import static dev.toonformat.jtoon.util.Constants.DOUBLE_QUOTE;
import static dev.toonformat.jtoon.util.Constants.FALSE_LITERAL;
import static dev.toonformat.jtoon.util.Constants.LIST_ITEM_MARKER;
@@ -11,18 +9,9 @@
/**
* Validates strings for safe unquoted usage in TOON format.
- * Follows Object Calisthenics principles with guard clauses and single-level
- * indentation.
+ * Uses char-by-char validation for performance instead of regex.
*/
public final class StringValidator {
- private static final Pattern NUMERIC_PATTERN = Pattern.compile("^-?\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?$",
- Pattern.CASE_INSENSITIVE);
-
- private static final Pattern OCTAL_PATTERN = Pattern.compile("^0[0-7]+$");
- private static final Pattern LEADING_ZERO_PATTERN = Pattern.compile("^0\\d+$");
- private static final Pattern UNQUOTED_KEY_PATTERN = Pattern.compile("^[A-Z_][\\w.]*$", Pattern.CASE_INSENSITIVE);
- private static final Pattern STRUCTURAL_CHARS = Pattern.compile("[\\[\\]{}]");
- private static final Pattern CONTROL_CHARS = Pattern.compile("[\\n\\r\\t]");
private StringValidator() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
@@ -30,46 +19,53 @@ private StringValidator() {
/**
* Checks if a string can be safely written without quotes.
- * Uses guard clauses and early returns for clarity.
+ * Uses char-by-char validation for performance.
*
* @param value the string value to check
* @param delimiter the delimiter being used (for validation)
* @return true if the string can be safely written without quotes, false otherwise
*/
public static boolean isSafeUnquoted(final String value, final String delimiter) {
- if (isNullOrEmpty(value)) {
+ if (value == null || value.isEmpty()) {
return false;
}
- if (isPaddedWithWhitespace(value)) {
- return false;
- }
-
- if (looksLikeKeyword(value)) {
- return false;
- }
-
- if (looksLikeNumber(value)) {
- return false;
- }
+ final int len = value.length();
- if (containsColon(value)) {
+ if (value.charAt(0) == ' ' || value.charAt(len - 1) == ' ') {
return false;
}
- if (containsQuotesOrBackslash(value)) {
+ if (isKeyword(value)) {
return false;
}
- if (containsStructuralCharacters(value)) {
+ if (isNumericLike(value)) {
return false;
}
- if (containsControlCharacters(value)) {
- return false;
+ for (int i = 0; i < len; i++) {
+ final char c = value.charAt(i);
+ switch (c) {
+ case ':':
+ case '"':
+ case '\\':
+ case '[':
+ case ']':
+ case '{':
+ case '}':
+ case '\n':
+ case '\r':
+ case '\t':
+ return false;
+ default:
+ if (delimiter.length() == 1 && c == delimiter.charAt(0)) {
+ return false;
+ }
+ }
}
- return !containsDelimiter(value, delimiter) && !startsWithListMarker(value);
+ return !value.startsWith(LIST_ITEM_MARKER);
}
/**
@@ -79,51 +75,86 @@ public static boolean isSafeUnquoted(final String value, final String delimiter)
* @return true if the key can be used without quotes, false otherwise
*/
public static boolean isValidUnquotedKey(final String key) {
- return UNQUOTED_KEY_PATTERN.matcher(key).matches();
- }
+ if (key == null || key.isEmpty()) {
+ return false;
+ }
- private static boolean isNullOrEmpty(final String value) {
- return value == null || value.isEmpty();
- }
+ final int len = key.length();
+ final char first = key.charAt(0);
- private static boolean isPaddedWithWhitespace(final String value) {
- return !value.equals(value.trim());
+ if (!Character.isJavaIdentifierStart(first) && first != '_') {
+ return false;
+ }
+
+ for (int i = 1; i < len; i++) {
+ final char c = key.charAt(i);
+ if (!Character.isJavaIdentifierPart(c) && c != '.') {
+ return false;
+ }
+ }
+
+ return true;
}
- private static boolean looksLikeKeyword(final String value) {
+ private static boolean isKeyword(final String value) {
return TRUE_LITERAL.equals(value)
|| FALSE_LITERAL.equals(value)
|| NULL_LITERAL.equals(value);
}
- private static boolean looksLikeNumber(final String value) {
- return OCTAL_PATTERN.matcher(value).matches()
- || LEADING_ZERO_PATTERN.matcher(value).matches()
- || NUMERIC_PATTERN.matcher(value).matches();
- }
+ private static boolean isNumericLike(final String value) {
+ if (value.isEmpty()) {
+ return false;
+ }
- private static boolean containsColon(final String value) {
- return value.contains(COLON);
- }
+ final int len = value.length();
+ int i = 0;
- static boolean containsQuotesOrBackslash(final String value) {
- return value.indexOf(DOUBLE_QUOTE) >= 0
- || value.indexOf(BACKSLASH) >= 0;
- }
-
- private static boolean containsStructuralCharacters(final String value) {
- return STRUCTURAL_CHARS.matcher(value).find();
- }
+ if (value.charAt(0) == '-') {
+ if (len < 2) {
+ return false;
+ }
+ i = 1;
+ }
- private static boolean containsControlCharacters(final String value) {
- return CONTROL_CHARS.matcher(value).find();
- }
+ boolean hasDigit = false;
+ boolean hasDot = false;
+ boolean hasExponent = false;
+
+ while (i < len) {
+ final char c = value.charAt(i);
+
+ if (c >= '0' && c <= '9') {
+ hasDigit = true;
+ } else if (c == '.') {
+ if (hasDot || hasExponent || !hasDigit) {
+ return false;
+ }
+ hasDot = true;
+ hasDigit = false;
+ } else if (c == 'e' || c == 'E') {
+ if (!hasDigit || hasExponent) {
+ return false;
+ }
+ hasExponent = true;
+ hasDigit = false;
+ if (i + 1 < len) {
+ final char next = value.charAt(i + 1);
+ if (next == '+' || next == '-') {
+ i++;
+ }
+ }
+ } else {
+ return false;
+ }
+ i++;
+ }
- private static boolean containsDelimiter(final String value, final String delimiter) {
- return value.contains(delimiter);
+ return hasDigit;
}
- private static boolean startsWithListMarker(final String value) {
- return value.startsWith(LIST_ITEM_MARKER);
+ static boolean containsQuotesOrBackslash(final String value) {
+ return value.indexOf(DOUBLE_QUOTE) >= 0
+ || value.indexOf(BACKSLASH) >= 0;
}
}
diff --git a/src/test/java/dev/toonformat/jtoon/EncodingOptimizationBenchmark.java b/src/test/java/dev/toonformat/jtoon/EncodingOptimizationBenchmark.java
new file mode 100644
index 0000000..5edf1a1
--- /dev/null
+++ b/src/test/java/dev/toonformat/jtoon/EncodingOptimizationBenchmark.java
@@ -0,0 +1,184 @@
+package dev.toonformat.jtoon;
+
+import dev.toonformat.jtoon.encoder.ValueEncoder;
+import dev.toonformat.jtoon.normalizer.JsonNormalizer;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.RunnerException;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+import tools.jackson.databind.JsonNode;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * JMH Benchmark measuring encoding throughput after optimizations.
+ * Tests both small and large objects to validate performance improvements.
+ */
+@State(Scope.Thread)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+public class EncodingOptimizationBenchmark {
+
+ @Param({"small", "large"})
+ private String size;
+
+ private JsonNode simpleObjectNode;
+ private JsonNode nestedObjectNode;
+ private JsonNode tabularArrayNode;
+ private JsonNode primitiveArrayNode;
+ private JsonNode complexMixedNode;
+
+ @Setup
+ public void setup() {
+ if ("small".equals(size)) {
+ simpleObjectNode = createSimpleObject(10);
+ nestedObjectNode = createNestedObject(3, 5);
+ tabularArrayNode = createTabularArray(10, 5);
+ primitiveArrayNode = createPrimitiveArray(20);
+ complexMixedNode = createComplexMixedDocument(5);
+ } else {
+ simpleObjectNode = createSimpleObject(100);
+ nestedObjectNode = createNestedObject(5, 20);
+ tabularArrayNode = createTabularArray(100, 10);
+ primitiveArrayNode = createPrimitiveArray(500);
+ complexMixedNode = createComplexMixedDocument(50);
+ }
+ }
+
+ private JsonNode createSimpleObject(int fieldCount) {
+ Map obj = new HashMap<>();
+ for (int i = 0; i < fieldCount; i++) {
+ obj.put("field_" + i, i % 5 == 0 ? "string_" + i : (i % 3 == 0 ? i * 1.5 : i));
+ }
+ return JsonNormalizer.normalize(obj);
+ }
+
+ private JsonNode createNestedObject(int depth, int fieldsPerLevel) {
+ Map current = new HashMap<>();
+ Map root = current;
+
+ for (int d = 0; d < depth; d++) {
+ Map level = new HashMap<>();
+ for (int i = 0; i < fieldsPerLevel; i++) {
+ level.put("level" + d + "_field" + i, i);
+ }
+ if (d < depth - 1) {
+ current.put("nested", level);
+ current = level;
+ }
+ }
+ return JsonNormalizer.normalize(root);
+ }
+
+ private JsonNode createTabularArray(int rowCount, int columnCount) {
+ List