diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 45e5fbf..61cf9ae 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -3,7 +3,7 @@ updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
- interval: "weekly"
+ interval: "monthly"
open-pull-requests-limit: 10
groups:
test:
@@ -13,4 +13,4 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
- interval: "weekly"
+ interval: "monthly"
diff --git a/.idea/misc.xml b/.idea/misc.xml
index cab6417..b777459 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -4,7 +4,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/sonarlint.xml b/.idea/sonarlint.xml
new file mode 100644
index 0000000..c78dfb6
--- /dev/null
+++ b/.idea/sonarlint.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000..8c6a9ef
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1 @@
+sonar.java.source=17
\ No newline at end of file
diff --git a/src/main/java/com/felipestanzani/jtoon/DecodeOptions.java b/src/main/java/com/felipestanzani/jtoon/DecodeOptions.java
index 212b9fc..b764556 100644
--- a/src/main/java/com/felipestanzani/jtoon/DecodeOptions.java
+++ b/src/main/java/com/felipestanzani/jtoon/DecodeOptions.java
@@ -10,21 +10,23 @@
* IllegalArgumentException on invalid input. When false,
* uses best-effort parsing and returns null on errors
* (default: true)
+ * @param expandPaths Path expansion mode for dotted keys (default: OFF)
*/
public record DecodeOptions(
int indent,
Delimiter delimiter,
- boolean strict) {
+ boolean strict,
+ PathExpansion expandPaths) {
/**
- * Default decoding options: 2 spaces indent, comma delimiter, strict validation
+ * Default decoding options: 2 spaces indent, comma delimiter, strict validation, path expansion off
*/
- public static final DecodeOptions DEFAULT = new DecodeOptions(2, Delimiter.COMMA, true);
+ public static final DecodeOptions DEFAULT = new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.OFF);
/**
* Creates DecodeOptions with default values.
*/
public DecodeOptions() {
- this(2, Delimiter.COMMA, true);
+ this(2, Delimiter.COMMA, true, PathExpansion.OFF);
}
/**
@@ -32,7 +34,7 @@ public DecodeOptions() {
* mode.
*/
public static DecodeOptions withIndent(int indent) {
- return new DecodeOptions(indent, Delimiter.COMMA, true);
+ return new DecodeOptions(indent, Delimiter.COMMA, true, PathExpansion.OFF);
}
/**
@@ -40,7 +42,7 @@ public static DecodeOptions withIndent(int indent) {
* mode.
*/
public static DecodeOptions withDelimiter(Delimiter delimiter) {
- return new DecodeOptions(2, delimiter, true);
+ return new DecodeOptions(2, delimiter, true, PathExpansion.OFF);
}
/**
@@ -48,6 +50,6 @@ public static DecodeOptions withDelimiter(Delimiter delimiter) {
* delimiter.
*/
public static DecodeOptions withStrict(boolean strict) {
- return new DecodeOptions(2, Delimiter.COMMA, strict);
+ return new DecodeOptions(2, Delimiter.COMMA, strict, PathExpansion.OFF);
}
}
diff --git a/src/main/java/com/felipestanzani/jtoon/JToon.java b/src/main/java/com/felipestanzani/jtoon/JToon.java
index a3eeb13..ff44767 100644
--- a/src/main/java/com/felipestanzani/jtoon/JToon.java
+++ b/src/main/java/com/felipestanzani/jtoon/JToon.java
@@ -4,40 +4,9 @@
import com.felipestanzani.jtoon.encoder.ValueEncoder;
import com.felipestanzani.jtoon.normalizer.JsonNormalizer;
import tools.jackson.databind.JsonNode;
-import tools.jackson.databind.ObjectMapper;
-/**
- * Main API for encoding and decoding JToon format.
- *
- *
- * JToon is a structured text format that represents JSON-like data in a more
- * human-readable way, with support for tabular arrays and inline formatting.
- *
- *
- * Usage Examples:
- *
- * {@code
- * // Encode a Java object with default options
- * String toon = JToon.encode(myObject);
- *
- * // Encode with custom options
- * EncodeOptions options = new EncodeOptions(4, Delimiter.PIPE, true);
- * String toon = JToon.encode(myObject, options);
- *
- * // Encode a plain JSON string directly
- * String toon = JToon.encodeJson("{\"id\":123,\"name\":\"Ada\"}");
- *
- * // Decode TOON back to Java objects
- * Object result = JToon.decode(toon);
- *
- * // Decode TOON directly to JSON string
- * String json = JToon.decodeToJson(toon);
- * }
- */
public final class JToon {
- private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
-
private JToon() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
@@ -179,11 +148,6 @@ public static String decodeToJson(String toon) {
* invalid
*/
public static String decodeToJson(String toon, DecodeOptions options) {
- try {
- Object decoded = ValueDecoder.decode(toon, options);
- return OBJECT_MAPPER.writeValueAsString(decoded);
- } catch (Exception e) {
- throw new IllegalArgumentException("Failed to convert decoded value to JSON: " + e.getMessage(), e);
- }
+ return ValueDecoder.decodeToJson(toon, options);
}
}
diff --git a/src/main/java/com/felipestanzani/jtoon/PathExpansion.java b/src/main/java/com/felipestanzani/jtoon/PathExpansion.java
new file mode 100644
index 0000000..d01c1df
--- /dev/null
+++ b/src/main/java/com/felipestanzani/jtoon/PathExpansion.java
@@ -0,0 +1,18 @@
+package com.felipestanzani.jtoon;
+
+/**
+ * Path expansion mode for decoding dotted keys.
+ */
+public enum PathExpansion {
+ /**
+ * Safe mode: expands dotted keys like "a.b.c" into nested objects.
+ * Only expands keys that are valid identifier segments.
+ */
+ SAFE,
+
+ /**
+ * Off mode: treats dotted keys as literal keys.
+ */
+ OFF
+}
+
diff --git a/src/main/java/com/felipestanzani/jtoon/decoder/PrimitiveDecoder.java b/src/main/java/com/felipestanzani/jtoon/decoder/PrimitiveDecoder.java
index ad3fb42..e9f2128 100644
--- a/src/main/java/com/felipestanzani/jtoon/decoder/PrimitiveDecoder.java
+++ b/src/main/java/com/felipestanzani/jtoon/decoder/PrimitiveDecoder.java
@@ -9,14 +9,15 @@
* Converts TOON scalar representations to appropriate Java types:
*
*
- * {@code "null"} → {@code null}
- * {@code "true"} / {@code "false"} → {@code Boolean}
- * Numeric strings → {@code Long} or {@code Double}
- * Quoted strings → {@code String} (with unescaping)
- * Bare strings → {@code String}
+ * {@code "null"} → {@code null}
+ * {@code "true"} / {@code "false"} → {@code Boolean}
+ * Numeric strings → {@code Long} or {@code Double}
+ * Quoted strings → {@code String} (with unescaping)
+ * Bare strings → {@code String}
*
*
* Examples:
+ *
* {@code
* parse("null") → null
* parse("true") → true
@@ -46,27 +47,52 @@ static Object parse(String value) {
}
// Check for null literal
- if ("null".equals(value)) {
- return null;
- }
-
- // Check for boolean literals
- if ("true".equals(value)) {
- return true;
- }
- if ("false".equals(value)) {
- return false;
+ switch (value) {
+ case "null" -> {
+ return null;
+ }
+ case "true" -> {
+ return true;
+ }
+ case "false" -> {
+ return false;
+ }
+ default -> {
+ // Do nothing, continue to next check
+ }
}
// Check for quoted strings
- if (value.startsWith("\"") && value.endsWith("\"")) {
+ if (value.startsWith("\"")) {
+ // Validate string before unescaping
+ StringEscaper.validateString(value);
return StringEscaper.unescape(value);
}
+ // Check for leading zeros (treat as string, except for "0", "-0", "0.0", etc.)
+ String trimmed = value.trim();
+ if (trimmed.length() > 1 && trimmed.matches("^-?0+[\\d].*")
+ && !trimmed.matches("^-?0+(\\.0+)?([eE][+-]?\\d+)?$")) {
+ return value;
+ }
+
// Try parsing as number
try {
+ // Check if it contains exponent notation or decimal point
if (value.contains(".") || value.contains("e") || value.contains("E")) {
- return Double.parseDouble(value);
+ double parsed = Double.parseDouble(value);
+ // Handle negative zero - Java doesn't distinguish, but spec says it should be 0
+ if (parsed == 0.0) {
+ return 0L;
+ }
+ // Check if the result is a whole number - if so, return as Long
+ if (parsed == Math.floor(parsed)
+ && !Double.isInfinite(parsed)
+ && parsed >= Long.MIN_VALUE
+ && parsed <= Long.MAX_VALUE)
+ return (long) parsed;
+
+ return parsed;
} else {
return Long.parseLong(value);
}
diff --git a/src/main/java/com/felipestanzani/jtoon/decoder/ValueDecoder.java b/src/main/java/com/felipestanzani/jtoon/decoder/ValueDecoder.java
index 66e07cd..8c99ed0 100644
--- a/src/main/java/com/felipestanzani/jtoon/decoder/ValueDecoder.java
+++ b/src/main/java/com/felipestanzani/jtoon/decoder/ValueDecoder.java
@@ -1,9 +1,12 @@
package com.felipestanzani.jtoon.decoder;
import com.felipestanzani.jtoon.DecodeOptions;
+import com.felipestanzani.jtoon.PathExpansion;
import com.felipestanzani.jtoon.util.StringEscaper;
+import tools.jackson.databind.ObjectMapper;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -20,10 +23,10 @@
*
* Parsing Strategy:
*
- * Split input into lines
- * Track current line position and indentation depth
- * Use regex patterns to detect structure (arrays, objects, primitives)
- * Recursively process nested structures
+ * Split input into lines
+ * Track current line position and indentation depth
+ * Use regex patterns to detect structure (arrays, objects, primitives)
+ * Recursively process nested structures
*
*
* @see DecodeOptions
@@ -31,21 +34,27 @@
*/
public final class ValueDecoder {
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
/**
* Matches standalone array headers: [3], [#2], [3\t], [2|]
+ * Group 1: optional # marker, Group 2: digits, Group 3: optional delimiter
*/
- private static final Pattern ARRAY_HEADER_PATTERN = Pattern.compile("^\\[(#?)\\d+[\\t|]?]");
+ private static final Pattern ARRAY_HEADER_PATTERN = Pattern.compile("^\\[(#?)(\\d+)([\\t|])?]");
/**
* Matches tabular array headers with field names: [2]{id,name,role}:
+ * Group 1: optional # marker, Group 2: digits, Group 3: optional delimiter,
+ * Group 4: field spec
*/
- private static final Pattern TABULAR_HEADER_PATTERN = Pattern.compile("^\\[(#?)\\d+[\\t|]?]\\{(.+)}:");
+ private static final Pattern TABULAR_HEADER_PATTERN = Pattern.compile("^\\[(#?)(\\d+)([\\t|])?]\\{(.+)}:");
/**
* Matches keyed array headers: items[2]{id,name}: or tags[3]: or data[4]{id}:
- * Captures: group(1)=key, group(2)=#marker, group(3)=optional field spec
+ * Captures: group(1)=key, group(2)=#marker, group(3)=delimiter,
+ * group(4)=optional field spec
*/
- private static final Pattern KEYED_ARRAY_PATTERN = Pattern.compile("^(.+?)\\[(#?)\\d+[\\t|]?](\\{[^}]+})?:.*$");
+ private static final Pattern KEYED_ARRAY_PATTERN = Pattern.compile("^(.+?)\\[(#?)\\d+([\\t|])?](\\{[^}]+})?:.*$");
private ValueDecoder() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
@@ -57,16 +66,58 @@ private ValueDecoder() {
* @param toon TOON-formatted input string
* @param options parsing options (delimiter, indentation, strict mode)
* @return parsed object (Map, List, primitive, or null)
- * @throws IllegalArgumentException if strict mode is enabled and input is invalid
+ * @throws IllegalArgumentException if strict mode is enabled and input is
+ * invalid
*/
public static Object decode(String toon, DecodeOptions options) {
if (toon == null || toon.trim().isEmpty()) {
- return null;
+ return new LinkedHashMap<>();
}
+ // Special case: if input is exactly "null", return null
String trimmed = toon.trim();
- Parser parser = new Parser(trimmed, options);
- return parser.parseValue();
+ if ("null".equals(trimmed)) {
+ return null;
+ }
+
+ // Don't trim leading whitespace - we need it for indentation validation
+ // Only trim trailing whitespace to avoid issues with empty lines at the end
+ String processed = toon;
+ while (!processed.isEmpty() && Character.isWhitespace(processed.charAt(processed.length() - 1))) {
+ processed = processed.substring(0, processed.length() - 1);
+ }
+
+ Parser parser = new Parser(processed, options);
+ Object result = parser.parseValue();
+ // If result is null (no content), return empty object
+ if (result == null) {
+ return new LinkedHashMap<>();
+ }
+ return result;
+ }
+
+ /**
+ * Decodes a TOON-formatted string directly to a JSON string using custom
+ * options.
+ *
+ *
+ * This is a convenience method that decodes TOON to Java objects and then
+ * serializes them to JSON.
+ *
+ *
+ * @param toon The TOON-formatted string to decode
+ * @param options Decoding options (indent, delimiter, strict mode)
+ * @return JSON string representation
+ * @throws IllegalArgumentException if strict mode is enabled and input is
+ * invalid
+ */
+ public static String decodeToJson(String toon, DecodeOptions options) {
+ try {
+ Object decoded = ValueDecoder.decode(toon, options);
+ return OBJECT_MAPPER.writeValueAsString(decoded);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Failed to convert decoded value to JSON: " + e.getMessage(), e);
+ }
}
/**
@@ -87,7 +138,7 @@ private static class Parser {
/**
* Parses the current line at root level (depth 0).
- * Routes to appropriate handler based on line content.
+ * Routes to appropriate handler based online content.
*/
Object parseValue() {
if (currentLine >= lines.length) {
@@ -98,10 +149,7 @@ Object parseValue() {
int depth = getDepth(line);
if (depth > 0) {
- if (options.strict()) {
- throw new IllegalArgumentException("Unexpected indentation at line " + currentLine);
- }
- return null;
+ return handleUnexpectedIndentation();
}
String content = line.substring(depth * options.indent());
@@ -114,14 +162,7 @@ Object parseValue() {
// Handle keyed arrays: items[2]{id,name}:
Matcher keyedArray = KEYED_ARRAY_PATTERN.matcher(content);
if (keyedArray.matches()) {
- String key = StringEscaper.unescape(keyedArray.group(1).trim());
- String arrayHeader = content.substring(keyedArray.group(1).length());
-
- @SuppressWarnings("unchecked")
- List arrayValue = (List) parseArray(arrayHeader, depth);
- Map obj = new LinkedHashMap<>();
- obj.put(key, arrayValue);
- return obj;
+ return parseKeyedArrayValue(keyedArray, content, depth);
}
// Handle key-value pairs: name: Ada
@@ -133,24 +174,139 @@ Object parseValue() {
}
// Bare scalar value
+ return parseBareScalarValue(content, depth);
+ }
+
+ /**
+ * Handles unexpected indentation at root level.
+ */
+ private Object handleUnexpectedIndentation() {
+ if (options.strict()) {
+ throw new IllegalArgumentException("Unexpected indentation at line " + currentLine);
+ }
+ return null;
+ }
+
+ /**
+ * Parses a keyed array value (e.g., "items[2]{id,name}:").
+ */
+ private Object parseKeyedArrayValue(Matcher keyedArray, String content, int depth) {
+ String originalKey = keyedArray.group(1).trim();
+ String key = StringEscaper.unescape(originalKey);
+ String arrayHeader = content.substring(keyedArray.group(1).length());
+
+ var arrayValue = parseArray(arrayHeader, depth);
+ Map obj = new LinkedHashMap<>();
+
+ // Handle path expansion for array keys
+ if (shouldExpandKey(originalKey)) {
+ expandPathIntoMap(obj, key, arrayValue);
+ } else {
+ // Check for conflicts with existing expanded paths
+ checkPathExpansionConflict(obj, key, arrayValue);
+ obj.put(key, arrayValue);
+ }
+
+ // Continue parsing root-level fields if at depth 0
+ if (depth == 0) {
+ parseRootObjectFields(obj, depth);
+ }
+
+ return obj;
+ }
+
+ /**
+ * Parses a bare scalar value and validates in strict mode.
+ */
+ private Object parseBareScalarValue(String content, int depth) {
+ Object result = PrimitiveDecoder.parse(content);
currentLine++;
- return PrimitiveDecoder.parse(content);
+
+ // In strict mode, check if there are more primitives at root level
+ if (options.strict() && depth == 0) {
+ validateNoMultiplePrimitivesAtRoot();
+ }
+
+ return result;
}
/**
- * Parses array from header string and following lines.
+ * Validates that there are no multiple primitives at root level in strict mode.
+ */
+ private void validateNoMultiplePrimitivesAtRoot() {
+ int lineIndex = currentLine;
+ while (lineIndex < lines.length && isBlankLine(lines[lineIndex])) {
+ lineIndex++;
+ }
+ if (lineIndex < lines.length) {
+ int nextDepth = getDepth(lines[lineIndex]);
+ if (nextDepth == 0) {
+ throw new IllegalArgumentException(
+ "Multiple primitives at root depth in strict mode at line " + (lineIndex + 1));
+ }
+ }
+ }
+
+ /**
+ * Extracts delimiter from array header.
+ * Returns tab, pipe, or comma (default) based on header pattern.
+ */
+ private String extractDelimiterFromHeader(String header) {
+ Matcher matcher = ARRAY_HEADER_PATTERN.matcher(header);
+ if (matcher.find()) {
+ String delimChar = matcher.group(3);
+ if (delimChar != null) {
+ if ("\t".equals(delimChar)) {
+ return "\t";
+ } else if ("|".equals(delimChar)) {
+ return "|";
+ }
+ }
+ }
+ // Default to comma
+ return delimiter;
+ }
+
+ /**
+ * Extracts declared length from array header.
+ * Returns the number specified in [n] or null if not found.
+ */
+ private Integer extractLengthFromHeader(String header) {
+ Matcher matcher = ARRAY_HEADER_PATTERN.matcher(header);
+ if (matcher.find()) {
+ try {
+ return Integer.parseInt(matcher.group(2));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Validates array length if declared in header.
+ */
+ private void validateArrayLength(String header, int actualLength) {
+ Integer declaredLength = extractLengthFromHeader(header);
+ if (declaredLength != null && declaredLength != actualLength) {
+ throw new IllegalArgumentException(
+ String.format("Array length mismatch: declared %d, found %d", declaredLength, actualLength));
+ }
+ }
+
+ /**
+ * Parses array from header string and following lines with a specific
+ * delimiter.
* Detects array type (tabular, list, or primitive) and routes accordingly.
*/
- private Object parseArray(String header, int depth) {
+ private List parseArrayWithDelimiter(String header, int depth, String arrayDelimiter) {
Matcher tabularMatcher = TABULAR_HEADER_PATTERN.matcher(header);
Matcher arrayMatcher = ARRAY_HEADER_PATTERN.matcher(header);
- // Tabular array: [2]{id,name}:
if (tabularMatcher.find()) {
- return parseTabularArray(header, depth);
+ return parseTabularArray(header, depth, arrayDelimiter);
}
- // Other arrays: [2]:
if (arrayMatcher.find()) {
int headerEndIdx = arrayMatcher.end();
String afterHeader = header.substring(headerEndIdx).trim();
@@ -158,110 +314,335 @@ private Object parseArray(String header, int depth) {
if (afterHeader.startsWith(":")) {
String inlineContent = afterHeader.substring(1).trim();
- // Inline primitive array: [3]: a,b,c
if (!inlineContent.isEmpty()) {
- List result = parseArrayValues(inlineContent);
+ List result = parseArrayValues(inlineContent, arrayDelimiter);
+ validateArrayLength(header, result.size());
currentLine++;
return result;
}
}
- // Multiline array
currentLine++;
if (currentLine < lines.length) {
String nextLine = lines[currentLine];
int nextDepth = getDepth(nextLine);
String nextContent = nextLine.substring(nextDepth * options.indent());
- // List array: starts with "- "
if (nextContent.startsWith("- ")) {
currentLine--;
- return parseListArray(depth);
+ return parseListArray(depth, header);
} else {
- // Multiline primitive array
currentLine++;
- return parseArrayValues(nextContent);
+ List result = parseArrayValues(nextContent, arrayDelimiter);
+ validateArrayLength(header, result.size());
+ return result;
}
}
- return new ArrayList<>();
+ List empty = new ArrayList<>();
+ validateArrayLength(header, 0);
+ return empty;
}
if (options.strict()) {
throw new IllegalArgumentException("Invalid array header: " + header);
}
- return null;
+ return Collections.emptyList();
+ }
+
+ /**
+ * Parses array from header string and following lines.
+ * Detects array type (tabular, list, or primitive) and routes accordingly.
+ */
+ private List parseArray(String header, int depth) {
+ String arrayDelimiter = extractDelimiterFromHeader(header);
+
+ return parseArrayWithDelimiter(header, depth, arrayDelimiter);
}
/**
- * Parses tabular array format where each row contains delimiter-separated values.
- * Example: items[2]{id,name}:\n 1,Ada\n 2,Bob
+ * Checks if a line is blank (empty or only whitespace).
*/
- private List parseTabularArray(String header, int depth) {
+ private boolean isBlankLine(String line) {
+ return line.trim().isEmpty();
+ }
+
+ /**
+ * Parses tabular array format where each row contains delimiter-separated
+ * values.
+ * Example: items[2]{id,name}:\n 1,Ada\n 2,Bob
+ */
+ private List parseTabularArray(String header, int depth, String arrayDelimiter) {
Matcher matcher = TABULAR_HEADER_PATTERN.matcher(header);
if (!matcher.find()) {
return new ArrayList<>();
}
- String keysStr = matcher.group(2);
- List keys = parseTabularKeys(keysStr);
+ String keysStr = matcher.group(4);
+ List keys = parseTabularKeys(keysStr, arrayDelimiter);
List result = new ArrayList<>();
currentLine++;
while (currentLine < lines.length) {
- String line = lines[currentLine];
- int lineDepth = getDepth(line);
-
- if (lineDepth < depth + 1) {
+ if (!processTabularArrayLine(depth, keys, arrayDelimiter, result)) {
break;
}
+ }
- if (lineDepth == depth + 1) {
- String rowContent = line.substring((depth + 1) * options.indent());
- Map row = parseTabularRow(rowContent, keys);
- result.add(row);
- }
+ validateArrayLength(header, result.size());
+ return result;
+ }
+
+ /**
+ * Processes a single line in a tabular array.
+ * Returns true if parsing should continue, false if array should terminate.
+ */
+ private boolean processTabularArrayLine(int depth, List keys, String arrayDelimiter,
+ List result) {
+ String line = lines[currentLine];
+
+ if (isBlankLine(line)) {
+ return !handleBlankLineInTabularArray(depth);
+ }
+
+ int lineDepth = getDepth(line);
+ if (shouldTerminateTabularArray(line, lineDepth, depth)) {
+ return false;
+ }
+
+ if (processTabularRow(line, lineDepth, depth, keys, arrayDelimiter, result)) {
currentLine++;
}
+ return true;
+ }
- return result;
+ /**
+ * Handles blank line processing in tabular array.
+ * Returns true if array should terminate, false if line should be skipped.
+ */
+ private boolean handleBlankLineInTabularArray(int depth) {
+ int nextNonBlankLine = findNextNonBlankLine(currentLine + 1);
+
+ if (nextNonBlankLine < lines.length) {
+ int nextDepth = getDepth(lines[nextNonBlankLine]);
+ if (nextDepth <= depth) {
+ return true; // Blank line is outside array - terminate
+ }
+ }
+
+ // Blank line is inside array
+ if (options.strict()) {
+ throw new IllegalArgumentException(
+ "Blank line inside tabular array at line " + (currentLine + 1));
+ }
+ // In non-strict mode, skip blank lines
+ currentLine++;
+ return false;
+ }
+
+ /**
+ * Finds the next non-blank line starting from the given index.
+ */
+ private int findNextNonBlankLine(int startIndex) {
+ int index = startIndex;
+ while (index < lines.length && isBlankLine(lines[index])) {
+ index++;
+ }
+ return index;
+ }
+
+ /**
+ * Determines if tabular array parsing should terminate based online depth.
+ * Returns true if array should terminate, false otherwise.
+ */
+ private boolean shouldTerminateTabularArray(String line, int lineDepth, int depth) {
+ if (lineDepth < depth + 1) {
+ if (lineDepth == depth) {
+ String content = line.substring(depth * options.indent());
+ int colonIdx = findUnquotedColon(content);
+ if (colonIdx > 0) {
+ return true; // Key-value pair at same depth - terminate array
+ }
+ }
+ return true; // Line depth is less than expected - terminate
+ }
+
+ // Check for key-value pair at expected row depth
+ if (lineDepth == depth + 1) {
+ String rowContent = line.substring((depth + 1) * options.indent());
+ int colonIdx = findUnquotedColon(rowContent);
+ return colonIdx > 0; // Key-value pair at same depth as rows - terminate array
+ }
+
+ return false;
+ }
+
+ /**
+ * Processes a tabular row if it matches the expected depth.
+ * Returns true if line was processed and currentLine should be incremented,
+ * false otherwise.
+ */
+ private boolean processTabularRow(String line, int lineDepth, int depth, List keys,
+ String arrayDelimiter, List result) {
+ if (lineDepth == depth + 1) {
+ String rowContent = line.substring((depth + 1) * options.indent());
+ Map row = parseTabularRow(rowContent, keys, arrayDelimiter);
+ result.add(row);
+ return true;
+ } else if (lineDepth > depth + 1) {
+ // Line is deeper than expected - might be nested content, skip it
+ currentLine++;
+ return false;
+ }
+ return true;
}
/**
* Parses list array format where items are prefixed with "- ".
- * Example: items[2]:\n - item1\n - item2
+ * Example: items[2]:\n - item1\n - item2
*/
- private List parseListArray(int depth) {
+ private List parseListArray(int depth, String header) {
List result = new ArrayList<>();
currentLine++;
- while (currentLine < lines.length) {
+ boolean shouldContinue = true;
+ while (shouldContinue && currentLine < lines.length) {
String line = lines[currentLine];
- int lineDepth = getDepth(line);
-
- if (lineDepth < depth + 1) {
- break;
- }
- if (lineDepth == depth + 1) {
- String content = line.substring((depth + 1) * options.indent());
- if (content.startsWith("- ")) {
- result.add(parseListItem(content, depth));
- continue;
+ if (isBlankLine(line)) {
+ if (handleBlankLineInListArray(depth)) {
+ shouldContinue = false;
+ }
+ } else {
+ int lineDepth = getDepth(line);
+ if (shouldTerminateListArray(lineDepth, depth, line)) {
+ shouldContinue = false;
+ } else {
+ processListArrayItem(line, lineDepth, depth, result);
}
}
- currentLine++;
}
+ if (header != null) {
+ validateArrayLength(header, result.size());
+ }
return result;
}
+ /**
+ * Handles blank line processing in list array.
+ * Returns true if array should terminate, false if line should be skipped.
+ */
+ private boolean handleBlankLineInListArray(int depth) {
+ int nextNonBlankLine = findNextNonBlankLine(currentLine + 1);
+
+ if (nextNonBlankLine >= lines.length) {
+ return true; // End of file - terminate array
+ }
+
+ int nextDepth = getDepth(lines[nextNonBlankLine]);
+ if (nextDepth <= depth) {
+ return true; // Blank line is outside array - terminate
+ }
+
+ // Blank line is inside array
+ if (options.strict()) {
+ throw new IllegalArgumentException("Blank line inside list array at line " + (currentLine + 1));
+ }
+ // In non-strict mode, skip blank lines
+ currentLine++;
+ return false;
+ }
+
+ /**
+ * Determines if list array parsing should terminate based online depth.
+ * Returns true if array should terminate, false otherwise.
+ */
+ private boolean shouldTerminateListArray(int lineDepth, int depth, String line) {
+ if (lineDepth < depth + 1) {
+ return true; // Line depth is less than expected - terminate
+ }
+ // Also terminate if line is at expected depth but doesn't start with "-"
+ if (lineDepth == depth + 1) {
+ String content = line.substring((depth + 1) * options.indent());
+ return !content.startsWith("-"); // Not an array item - terminate
+ }
+ return false;
+ }
+
+ /**
+ * Processes a single list array item if it matches the expected depth.
+ */
+ private void processListArrayItem(String line, int lineDepth, int depth, List result) {
+ if (lineDepth == depth + 1) {
+ String content = line.substring((depth + 1) * options.indent());
+
+ if (content.startsWith("-")) {
+ result.add(parseListItem(content, depth));
+ } else {
+ currentLine++;
+ }
+ } else {
+ currentLine++;
+ }
+ }
+
/**
* Parses a single list item starting with "- ".
* Item can be a scalar value or an object with nested fields.
*/
private Object parseListItem(String content, int depth) {
- String itemContent = content.substring(2).trim();
+ // Handle empty item: just "-" or "- "
+ String itemContent;
+ if (content.length() > 2) {
+ itemContent = content.substring(2).trim();
+ } else {
+ itemContent = "";
+ }
+
+ // Handle empty item: just "-"
+ if (itemContent.isEmpty()) {
+ currentLine++;
+ return new LinkedHashMap<>();
+ }
+
+ // Check for standalone array (e.g., "[2]: 1,2")
+ if (itemContent.startsWith("[")) {
+ // For nested arrays in list items, default to comma delimiter if not specified
+ String nestedArrayDelimiter = extractDelimiterFromHeader(itemContent);
+ // parseArrayWithDelimiter handles currentLine increment internally
+ // For inline arrays, it increments. For multi-line arrays, parseListArray
+ // handles it.
+ // We need to increment here only if it was an inline array that we just parsed
+ // Actually, parseArrayWithDelimiter always handles currentLine, so we don't
+ // need to increment
+ return parseArrayWithDelimiter(itemContent, depth + 1, nestedArrayDelimiter);
+ }
+
+ // Check for keyed array pattern (e.g., "tags[3]: a,b,c" or "data[2]{id}: ...")
+ Matcher keyedArray = KEYED_ARRAY_PATTERN.matcher(itemContent);
+ if (keyedArray.matches()) {
+ String originalKey = keyedArray.group(1).trim();
+ String key = StringEscaper.unescape(originalKey);
+ String arrayHeader = itemContent.substring(keyedArray.group(1).length());
+
+ // For nested arrays in list items, default to comma delimiter if not specified
+ String nestedArrayDelimiter = extractDelimiterFromHeader(arrayHeader);
+ var arrayValue = parseArrayWithDelimiter(arrayHeader, depth + 1, nestedArrayDelimiter);
+
+ Map item = new LinkedHashMap<>();
+ item.put(key, arrayValue);
+
+ // parseArrayWithDelimiter manages currentLine correctly:
+ // - For inline arrays, it increments currentLine
+ // - For multi-line arrays (list/tabular), the array parsers leave currentLine
+ // at the line after the array
+ // So we don't need to increment here. Just parse additional fields.
+ parseListItemFields(item, depth);
+
+ return item;
+ }
+
int colonIdx = findUnquotedColon(itemContent);
// Simple scalar: - value
@@ -275,14 +656,169 @@ private Object parseListItem(String content, int depth) {
String value = itemContent.substring(colonIdx + 1).trim();
Map item = new LinkedHashMap<>();
- item.put(key, PrimitiveDecoder.parse(value));
-
- currentLine++;
+ // List item is at depth + 1, so pass depth + 1 to parseObjectItemValue
+ Object parsedValue = parseObjectItemValue(value, depth + 1);
+ item.put(key, parsedValue);
parseListItemFields(item, depth);
return item;
}
+ /**
+ * Parses the value portion of an object item in a list, handling nested
+ * objects,
+ * empty values, and primitives.
+ *
+ * @param value the value string to parse
+ * @param depth the depth of the list item
+ * @return the parsed value (Map, List, or primitive)
+ */
+ private Object parseObjectItemValue(String value, int depth) {
+ currentLine++;
+ boolean isEmpty = value.trim().isEmpty();
+
+ // If no next line exists, handle simple case
+ if (currentLine >= lines.length) {
+ return isEmpty ? new LinkedHashMap<>() : PrimitiveDecoder.parse(value);
+ }
+
+ // Find next non-blank line and its depth
+ Integer nextDepth = findNextNonBlankLineDepth();
+ if (nextDepth == null) {
+ // No non-blank line found - create empty object
+ return new LinkedHashMap<>();
+ }
+
+ // Handle empty value with nested content
+ // The list item is at depth, and the field itself is conceptually at depth + 1
+ // So nested content should be parsed with parentDepth = depth + 1
+ // This allows nested fields at depth + 2 or deeper to be processed correctly
+ if (isEmpty && nextDepth > depth) {
+ return parseNestedObject(depth + 1);
+ }
+
+ // Handle empty value without nested content or non-empty value
+ return isEmpty ? new LinkedHashMap<>() : PrimitiveDecoder.parse(value);
+ }
+
+ /**
+ * Finds the depth of the next non-blank line, skipping blank lines.
+ *
+ * @return the depth of the next non-blank line, or null if none exists
+ */
+ private Integer findNextNonBlankLineDepth() {
+ int nextLineIdx = currentLine;
+ while (nextLineIdx < lines.length && isBlankLine(lines[nextLineIdx])) {
+ nextLineIdx++;
+ }
+
+ if (nextLineIdx >= lines.length) {
+ return null;
+ }
+
+ return getDepth(lines[nextLineIdx]);
+ }
+
+ /**
+ * Parses a field value, handling nested objects, empty values, and primitives.
+ *
+ * @param fieldValue the value string to parse
+ * @param fieldDepth the depth at which the field is located
+ * @return the parsed value (Map, List, or primitive)
+ */
+ private Object parseFieldValue(String fieldValue, int fieldDepth) {
+ // Check if next line is nested
+ if (currentLine + 1 < lines.length) {
+ int nextDepth = getDepth(lines[currentLine + 1]);
+ if (nextDepth > fieldDepth) {
+ currentLine++;
+ // parseNestedObject manages currentLine, so we don't increment here
+ return parseNestedObject(fieldDepth);
+ } else {
+ // If value is empty, create empty object; otherwise parse as primitive
+ if (fieldValue.trim().isEmpty()) {
+ currentLine++;
+ return new LinkedHashMap<>();
+ } else {
+ currentLine++;
+ return PrimitiveDecoder.parse(fieldValue);
+ }
+ }
+ } else {
+ // If value is empty, create empty object; otherwise parse as primitive
+ if (fieldValue.trim().isEmpty()) {
+ currentLine++;
+ return new LinkedHashMap<>();
+ } else {
+ currentLine++;
+ return PrimitiveDecoder.parse(fieldValue);
+ }
+ }
+ }
+
+ /**
+ * Parses a keyed array field and adds it to the item map.
+ *
+ * @param fieldContent the field content to parse
+ * @param item the map to add the field to
+ * @param depth the depth of the list item
+ * @return true if the field was processed as a keyed array, false otherwise
+ */
+ private boolean parseKeyedArrayField(String fieldContent, Map item, int depth) {
+ Matcher keyedArray = KEYED_ARRAY_PATTERN.matcher(fieldContent);
+ if (!keyedArray.matches()) {
+ return false;
+ }
+
+ String originalKey = keyedArray.group(1).trim();
+ String key = StringEscaper.unescape(originalKey);
+ String arrayHeader = fieldContent.substring(keyedArray.group(1).length());
+
+ // For nested arrays in list items, default to comma delimiter if not specified
+ String nestedArrayDelimiter = extractDelimiterFromHeader(arrayHeader);
+ var arrayValue = parseArrayWithDelimiter(arrayHeader, depth + 2, nestedArrayDelimiter);
+
+ // Handle path expansion for array keys
+ if (shouldExpandKey(originalKey)) {
+ expandPathIntoMap(item, key, arrayValue);
+ } else {
+ item.put(key, arrayValue);
+ }
+
+ // parseArrayWithDelimiter manages currentLine correctly
+ return true;
+ }
+
+ /**
+ * Parses a key-value field and adds it to the item map.
+ *
+ * @param fieldContent the field content to parse
+ * @param item the map to add the field to
+ * @param depth the depth of the list item
+ * @return true if the field was processed as a key-value pair, false otherwise
+ */
+ private boolean parseKeyValueField(String fieldContent, Map item, int depth) {
+ int colonIdx = findUnquotedColon(fieldContent);
+ if (colonIdx <= 0) {
+ return false;
+ }
+
+ String fieldKey = StringEscaper.unescape(fieldContent.substring(0, colonIdx).trim());
+ String fieldValue = fieldContent.substring(colonIdx + 1).trim();
+
+ Object parsedValue = parseFieldValue(fieldValue, depth + 2);
+
+ // Handle path expansion
+ if (shouldExpandKey(fieldKey)) {
+ expandPathIntoMap(item, fieldKey, parsedValue);
+ } else {
+ item.put(fieldKey, parsedValue);
+ }
+
+ // parseFieldValue manages currentLine appropriately
+ return true;
+ }
+
/**
* Parses additional fields for a list item object.
*/
@@ -292,29 +828,43 @@ private void parseListItemFields(Map item, int depth) {
int lineDepth = getDepth(line);
if (lineDepth < depth + 2) {
- break;
+ return;
}
if (lineDepth == depth + 2) {
String fieldContent = line.substring((depth + 2) * options.indent());
- int colonIdx = findUnquotedColon(fieldContent);
- if (colonIdx > 0) {
- String fieldKey = StringEscaper.unescape(fieldContent.substring(0, colonIdx).trim());
- String fieldValue = fieldContent.substring(colonIdx + 1).trim();
- item.put(fieldKey, PrimitiveDecoder.parse(fieldValue));
+ // Try to parse as keyed array first, then as key-value pair
+ boolean wasParsed = parseKeyedArrayField(fieldContent, item, depth);
+ if (!wasParsed) {
+ wasParsed = parseKeyValueField(fieldContent, item, depth);
}
+
+ // If neither pattern matched, skip this line to avoid infinite loop
+ if (!wasParsed) {
+ currentLine++;
+ }
+ } else {
+ // lineDepth > depth + 2, skip this line
+ currentLine++;
}
- currentLine++;
}
}
/**
* Parses a tabular row into a Map using the provided keys.
+ * Validates that the row uses the correct delimiter.
*/
- private Map parseTabularRow(String rowContent, List keys) {
+ private Map parseTabularRow(String rowContent, List keys, String arrayDelimiter) {
Map row = new LinkedHashMap<>();
- List values = parseArrayValues(rowContent);
+ List values = parseArrayValues(rowContent, arrayDelimiter);
+
+ // Validate value count matches key count
+ if (options.strict() && values.size() != keys.size()) {
+ throw new IllegalArgumentException(
+ String.format("Tabular row value count (%d) does not match header field count (%d)",
+ values.size(), keys.size()));
+ }
for (int i = 0; i < keys.size() && i < values.size(); i++) {
row.put(keys.get(i), values.get(i));
@@ -325,22 +875,68 @@ private Map parseTabularRow(String rowContent, List keys
/**
* Parses tabular header keys from field specification.
+ * Validates delimiter consistency between bracket and brace fields.
*/
- private List parseTabularKeys(String keysStr) {
+ private List parseTabularKeys(String keysStr, String arrayDelimiter) {
+ // Validate delimiter mismatch between bracket and brace fields
+ if (options.strict()) {
+ validateKeysDelimiter(keysStr, arrayDelimiter);
+ }
+
List result = new ArrayList<>();
- List rawValues = parseDelimitedValues(keysStr);
+ List rawValues = parseDelimitedValues(keysStr, arrayDelimiter);
for (String key : rawValues) {
result.add(StringEscaper.unescape(key));
}
return result;
}
+ /**
+ * Validates delimiter consistency in tabular header keys.
+ */
+ private void validateKeysDelimiter(String keysStr, String expectedDelimiter) {
+ char expectedChar = expectedDelimiter.charAt(0);
+ boolean inQuotes = false;
+ boolean escaped = false;
+
+ for (int i = 0; i < keysStr.length(); i++) {
+ char c = keysStr.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (c == '\\') {
+ escaped = true;
+ } else if (c == '"') {
+ inQuotes = !inQuotes;
+ } else if (!inQuotes) {
+ checkDelimiterMismatch(expectedChar, c);
+ }
+ }
+ }
+
+ /**
+ * Checks for delimiter mismatch and throws an exception if found.
+ */
+ private void checkDelimiterMismatch(char expectedChar, char actualChar) {
+ if (expectedChar == '\t' && actualChar == ',') {
+ throw new IllegalArgumentException(
+ "Delimiter mismatch: bracket declares tab, brace fields use comma");
+ }
+ if (expectedChar == '|' && actualChar == ',') {
+ throw new IllegalArgumentException(
+ "Delimiter mismatch: bracket declares pipe, brace fields use comma");
+ }
+ if (expectedChar == ',' && (actualChar == '\t' || actualChar == '|')) {
+ throw new IllegalArgumentException(
+ "Delimiter mismatch: bracket declares comma, brace fields use different delimiter");
+ }
+ }
+
/**
* Parses array values from a delimiter-separated string.
*/
- private List parseArrayValues(String values) {
+ private List parseArrayValues(String values, String arrayDelimiter) {
List result = new ArrayList<>();
- List rawValues = parseDelimitedValues(values);
+ List rawValues = parseDelimitedValues(values, arrayDelimiter);
for (String value : rawValues) {
result.add(PrimitiveDecoder.parse(value));
}
@@ -349,44 +945,48 @@ private List parseArrayValues(String values) {
/**
* Splits a string by delimiter, respecting quoted sections.
+ * Whitespace around delimiters is tolerated and trimmed.
*/
- private List parseDelimitedValues(String input) {
+ private List parseDelimitedValues(String input, String arrayDelimiter) {
List result = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
boolean escaped = false;
+ char delimChar = arrayDelimiter.charAt(0);
- for (int i = 0; i < input.length(); i++) {
+ int i = 0;
+ while (i < input.length()) {
char c = input.charAt(i);
if (escaped) {
current.append(c);
escaped = false;
- continue;
- }
-
- if (c == '\\') {
+ i++;
+ } else if (c == '\\') {
current.append(c);
escaped = true;
- continue;
- }
-
- if (c == '"') {
+ i++;
+ } else if (c == '"') {
current.append(c);
inQuotes = !inQuotes;
- continue;
- }
-
- if (c == delimiter.charAt(0) && !inQuotes) {
- result.add(current.toString().trim());
+ i++;
+ } else if (c == delimChar && !inQuotes) {
+ // Found delimiter - add current value (trimmed) and reset
+ String value = current.toString().trim();
+ result.add(value);
current = new StringBuilder();
- continue;
+ // Skip whitespace after delimiter
+ do {
+ i++;
+ } while (i < input.length() && Character.isWhitespace(input.charAt(i)));
+ } else {
+ current.append(c);
+ i++;
}
-
- current.append(c);
}
- if (!current.isEmpty() || input.endsWith(String.valueOf(delimiter))) {
+ // Add final value
+ if (!current.isEmpty() || input.endsWith(arrayDelimiter)) {
result.add(current.toString().trim());
}
@@ -396,107 +996,311 @@ private List parseDelimitedValues(String input) {
/**
* Parses additional key-value pairs at root level.
*/
- @SuppressWarnings("unchecked")
private void parseRootObjectFields(Map obj, int depth) {
while (currentLine < lines.length) {
String line = lines[currentLine];
int lineDepth = getDepth(line);
if (lineDepth != depth) {
- break;
+ return;
+ }
+
+ // Skip blank lines
+ if (isBlankLine(line)) {
+ currentLine++;
+ continue;
}
String content = line.substring(depth * options.indent());
- // Check for keyed array
Matcher keyedArray = KEYED_ARRAY_PATTERN.matcher(content);
if (keyedArray.matches()) {
- String key = StringEscaper.unescape(keyedArray.group(1).trim());
- String arrayHeader = content.substring(keyedArray.group(1).length());
- List arrayValue = (List) parseArray(arrayHeader, depth);
- obj.put(key, arrayValue);
- continue;
- }
+ processRootKeyedArrayLine(obj, content, keyedArray, depth);
+ } else {
+ int colonIdx = findUnquotedColon(content);
+ if (colonIdx > 0) {
+ String key = content.substring(0, colonIdx).trim();
+ String value = content.substring(colonIdx + 1).trim();
- int colonIdx = findUnquotedColon(content);
- if (colonIdx <= 0) {
- break;
+ parseKeyValuePairIntoMap(obj, key, value, depth);
+ } else {
+ return;
+ }
}
+ }
+ }
- String key = content.substring(0, colonIdx).trim();
- String value = content.substring(colonIdx + 1).trim();
- parseKeyValuePairIntoMap(obj, key, value, depth);
- currentLine++;
+ /**
+ * Processes a keyed array line in root object fields.
+ */
+ private void processRootKeyedArrayLine(Map obj, String content, Matcher keyedArray, int depth) {
+ String originalKey = keyedArray.group(1).trim();
+ String key = StringEscaper.unescape(originalKey);
+ String arrayHeader = content.substring(keyedArray.group(1).length());
+
+ var arrayValue = parseArray(arrayHeader, depth);
+
+ // Handle path expansion for array keys
+ if (shouldExpandKey(originalKey)) {
+ expandPathIntoMap(obj, key, arrayValue);
+ } else {
+ // Check for conflicts with existing expanded paths
+ checkPathExpansionConflict(obj, key, arrayValue);
+ obj.put(key, arrayValue);
}
}
/**
* Parses nested object starting at currentLine.
*/
- @SuppressWarnings("unchecked")
- private Object parseNestedObject(int parentDepth) {
- Map obj = new LinkedHashMap<>();
+ private Map parseNestedObject(int parentDepth) {
+ Map result = new LinkedHashMap<>();
while (currentLine < lines.length) {
String line = lines[currentLine];
+
+ // Skip blank lines
+ if (isBlankLine(line)) {
+ currentLine++;
+ continue;
+ }
+
int depth = getDepth(line);
if (depth <= parentDepth) {
- break;
+ return result;
}
if (depth == parentDepth + 1) {
- String content = line.substring((parentDepth + 1) * options.indent());
-
- // Check for keyed array
- Matcher keyedArray = KEYED_ARRAY_PATTERN.matcher(content);
- if (keyedArray.matches()) {
- String key = StringEscaper.unescape(keyedArray.group(1).trim());
- String arrayHeader = content.substring(keyedArray.group(1).length());
- List arrayValue = (List) parseArray(arrayHeader, parentDepth + 1);
- obj.put(key, arrayValue);
- continue;
- }
+ processDirectChildLine(result, line, parentDepth, depth);
+ } else {
+ currentLine++;
+ }
+ }
- int colonIdx = findUnquotedColon(content);
- if (colonIdx > 0) {
- String key = content.substring(0, colonIdx).trim();
- String value = content.substring(colonIdx + 1).trim();
- parseKeyValuePairIntoMap(obj, key, value, depth);
- }
+ return result;
+ }
+
+ /**
+ * Processes a line at depth == parentDepth + 1 (direct child).
+ * Returns true if the line was processed, false if it was a blank line that was
+ * skipped.
+ */
+ private void processDirectChildLine(Map result, String line, int parentDepth, int depth) {
+ // Skip blank lines
+ if (isBlankLine(line)) {
+ currentLine++;
+ return;
+ }
+
+ String content = line.substring((parentDepth + 1) * options.indent());
+ Matcher keyedArray = KEYED_ARRAY_PATTERN.matcher(content);
+
+ if (keyedArray.matches()) {
+ processKeyedArrayLine(result, content, keyedArray, parentDepth);
+ } else {
+ processKeyValueLine(result, content, depth);
+ }
+ }
+
+ /**
+ * Processes a keyed array line (e.g., "key[3]: value").
+ */
+ private void processKeyedArrayLine(Map result, String content, Matcher keyedArray,
+ int parentDepth) {
+ String originalKey = keyedArray.group(1).trim();
+ String key = StringEscaper.unescape(originalKey);
+ String arrayHeader = content.substring(keyedArray.group(1).length());
+ List arrayValue = parseArray(arrayHeader, parentDepth + 1);
+
+ // Handle path expansion for array keys
+ if (shouldExpandKey(originalKey)) {
+ expandPathIntoMap(result, key, arrayValue);
+ } else {
+ // Check for conflicts with existing expanded paths
+ checkPathExpansionConflict(result, key, arrayValue);
+ result.put(key, arrayValue);
+ }
+ }
+
+ /**
+ * Processes a key-value line (e.g., "key: value").
+ */
+ private void processKeyValueLine(Map result, String content, int depth) {
+ int colonIdx = findUnquotedColon(content);
+
+ if (colonIdx > 0) {
+ String key = content.substring(0, colonIdx).trim();
+ String value = content.substring(colonIdx + 1).trim();
+ parseKeyValuePairIntoMap(result, key, value, depth);
+ } else {
+ // No colon found in key-value context - this is an error
+ if (options.strict()) {
+ throw new IllegalArgumentException(
+ "Missing colon in key-value context at line " + (currentLine + 1));
}
currentLine++;
}
+ }
- return obj;
+ /**
+ * Checks if a key should be expanded (is a valid identifier segment).
+ * Keys with dots that are valid identifiers can be expanded.
+ * Quoted keys are never expanded.
+ */
+ private boolean shouldExpandKey(String key) {
+ if (options.expandPaths() != PathExpansion.SAFE) {
+ return false;
+ }
+ // Quoted keys should not be expanded
+ if (key.trim().startsWith("\"") && key.trim().endsWith("\"")) {
+ return false;
+ }
+ // Check if key contains dots and is a valid identifier pattern
+ if (!key.contains(".")) {
+ return false;
+ }
+ // Valid identifier: starts with letter or underscore, followed by letters,
+ // digits, underscores
+ // Each segment must match this pattern
+ String[] segments = key.split("\\.");
+ for (String segment : segments) {
+ if (!segment.matches("^[a-zA-Z_]\\w*$")) {
+ return false;
+ }
+ }
+ return true;
}
/**
- * Parses a key-value pair at root level, creating a new Map.
+ * Expands a dotted key into nested object structure.
*/
- private Object parseKeyValuePair(String key, String value, int depth, boolean parseRootFields) {
- key = StringEscaper.unescape(key);
+ private void expandPathIntoMap(Map map, String dottedKey, Object value) {
+ String[] segments = dottedKey.split("\\.");
+ Map current = map;
+
+ // Navigate/create nested structure
+ for (int i = 0; i < segments.length - 1; i++) {
+ String segment = segments[i];
+ Object existing = current.get(segment);
+
+ if (existing == null) {
+ // Create new nested object
+ Map nested = new LinkedHashMap<>();
+ current.put(segment, nested);
+ current = nested;
+ } else if (existing instanceof Map) {
+ // Use existing nested object
+ @SuppressWarnings("unchecked")
+ Map existingMap = (Map) existing;
+ current = existingMap;
+ } else {
+ // Conflict: existing is not a Map
+ if (options.strict()) {
+ throw new IllegalArgumentException(
+ String.format("Path expansion conflict: %s is %s, cannot expand to object",
+ segment, existing.getClass().getSimpleName()));
+ }
+ // LWW: overwrite with new nested object
+ Map nested = new LinkedHashMap<>();
+ current.put(segment, nested);
+ current = nested;
+ }
+ }
+
+ // Set final value
+ String finalSegment = segments[segments.length - 1];
+ Object existing = current.get(finalSegment);
+ checkFinalValueConflict(finalSegment, existing, value);
+
+ // LWW: last write wins (always overwrite in non-strict, or if types match in
+ // strict)
+ current.put(finalSegment, value);
+ }
+
+ private void checkFinalValueConflict(String finalSegment, Object existing, Object value) {
+ if (existing != null && options.strict()) {
+ // Check for conflicts in strict mode
+ if (existing instanceof Map && !(value instanceof Map)) {
+ throw new IllegalArgumentException(
+ String.format("Path expansion conflict: %s is object, cannot set to %s",
+ finalSegment, value.getClass().getSimpleName()));
+ }
+ if (existing instanceof List && !(value instanceof List)) {
+ throw new IllegalArgumentException(
+ String.format("Path expansion conflict: %s is array, cannot set to %s",
+ finalSegment, value.getClass().getSimpleName()));
+ }
+ }
+ }
+
+ /**
+ * Parses a key-value string into an Object, handling nested objects, empty
+ * values, and primitives.
+ *
+ * @param value the value string to parse
+ * @param depth the depth at which the key-value pair is located
+ * @return the parsed value (Map, List, or primitive)
+ */
+ private Object parseKeyValue(String value, int depth) {
// Check if next line is nested (deeper indentation)
if (currentLine + 1 < lines.length) {
int nextDepth = getDepth(lines[currentLine + 1]);
if (nextDepth > depth) {
currentLine++;
- Map obj = new LinkedHashMap<>();
- obj.put(key, parseNestedObject(depth));
-
- if (parseRootFields) {
- parseRootObjectFields(obj, depth);
+ // parseNestedObject manages currentLine, so we don't increment here
+ return parseNestedObject(depth);
+ } else {
+ // If value is empty, create empty object; otherwise parse as primitive
+ Object parsedValue;
+ if (value.trim().isEmpty()) {
+ parsedValue = new LinkedHashMap<>();
+ } else {
+ parsedValue = PrimitiveDecoder.parse(value);
}
- return obj;
+ currentLine++;
+ return parsedValue;
}
+ } else {
+ // If value is empty, create empty object; otherwise parse as primitive
+ Object parsedValue;
+ if (value.trim().isEmpty()) {
+ parsedValue = new LinkedHashMap<>();
+ } else {
+ parsedValue = PrimitiveDecoder.parse(value);
+ }
+ currentLine++;
+ return parsedValue;
}
+ }
- // Simple key-value pair
- currentLine++;
- Object parsedValue = PrimitiveDecoder.parse(value);
+ /**
+ * Puts a key-value pair into a map, handling path expansion.
+ *
+ * @param map the map to put the key-value pair into
+ * @param originalKey the original key before being unescaped (used for path
+ * expansion check)
+ * @param unescapedKey the unescaped key
+ * @param value the value to put
+ */
+ private void putKeyValueIntoMap(Map map, String originalKey, String unescapedKey,
+ Object value) {
+ // Handle path expansion
+ if (shouldExpandKey(originalKey)) {
+ expandPathIntoMap(map, unescapedKey, value);
+ } else {
+ checkPathExpansionConflict(map, unescapedKey, value);
+ map.put(unescapedKey, value);
+ }
+ }
+
+ /**
+ * Parses a key-value pair at root level, creating a new Map.
+ */
+ private Object parseKeyValuePair(String key, String value, int depth, boolean parseRootFields) {
Map obj = new LinkedHashMap<>();
- obj.put(key, parsedValue);
+ parseKeyValuePairIntoMap(obj, key, value, depth);
if (parseRootFields) {
parseRootObjectFields(obj, depth);
@@ -508,19 +1312,23 @@ private Object parseKeyValuePair(String key, String value, int depth, boolean pa
* Parses a key-value pair and adds it to an existing map.
*/
private void parseKeyValuePairIntoMap(Map map, String key, String value, int depth) {
- key = StringEscaper.unescape(key);
+ String unescapedKey = StringEscaper.unescape(key);
- // Check if next line is nested
- if (currentLine + 1 < lines.length) {
- int nextDepth = getDepth(lines[currentLine + 1]);
- if (nextDepth > depth) {
- currentLine++;
- map.put(key, parseNestedObject(depth));
- return;
- }
+ Object parsedValue = parseKeyValue(value, depth);
+ putKeyValueIntoMap(map, key, unescapedKey, parsedValue);
+ }
+
+ /**
+ * Checks for path expansion conflicts when setting a non-expanded key.
+ * In strict mode, throws if the key conflicts with an existing expanded path.
+ */
+ private void checkPathExpansionConflict(Map map, String key, Object value) {
+ if (!options.strict()) {
+ return;
}
- map.put(key, PrimitiveDecoder.parse(value));
+ Object existing = map.get(key);
+ checkFinalValueConflict(key, existing, value);
}
/**
@@ -536,20 +1344,11 @@ private int findUnquotedColon(String content) {
if (escaped) {
escaped = false;
- continue;
- }
-
- if (c == '\\') {
+ } else if (c == '\\') {
escaped = true;
- continue;
- }
-
- if (c == '"') {
+ } else if (c == '"') {
inQuotes = !inQuotes;
- continue;
- }
-
- if (c == ':' && !inQuotes) {
+ } else if (c == ':' && !inQuotes) {
return i;
}
}
@@ -560,21 +1359,84 @@ private int findUnquotedColon(String content) {
/**
* Calculates indentation depth (nesting level) of a line.
* Counts leading spaces in multiples of the configured indent size.
+ * In strict mode, validates indentation (no tabs, proper multiples).
*/
private int getDepth(String line) {
- int depth = 0;
+ // Blank lines (including lines with only spaces) have depth 0
+ if (isBlankLine(line)) {
+ return 0;
+ }
+
+ // Validate indentation (including tabs) in strict mode
+ // Check for tabs first before any other processing
+ if (options.strict() && !line.isEmpty() && line.charAt(0) == '\t') {
+ throw new IllegalArgumentException(
+ String.format("Tab character used in indentation at line %d", currentLine + 1));
+ }
+
+ if (options.strict()) {
+ validateIndentation(line);
+ }
+
+ int depth;
int indentSize = options.indent();
+ int leadingSpaces = 0;
- for (int i = 0; i < line.length(); i += indentSize) {
- if (i + indentSize <= line.length()
- && line.substring(i, i + indentSize).equals(" ".repeat(indentSize))) {
- depth++;
+ // Count leading spaces
+ for (int i = 0; i < line.length(); i++) {
+ if (line.charAt(i) == ' ') {
+ leadingSpaces++;
} else {
break;
}
}
+ // Calculate depth based on indent size
+ depth = leadingSpaces / indentSize;
+
+ // In strict mode, check if it's an exact multiple
+ if (options.strict() && leadingSpaces > 0
+ && leadingSpaces % indentSize != 0) {
+ throw new IllegalArgumentException(
+ String.format("Non-multiple indentation: %d spaces with indent=%d at line %d",
+ leadingSpaces, indentSize, currentLine + 1));
+ }
+
return depth;
}
+
+ /**
+ * Validates indentation in strict mode.
+ * Checks for tabs, mixed tabs/spaces, and non-multiple indentation.
+ */
+ private void validateIndentation(String line) {
+ if (line.trim().isEmpty()) {
+ // Blank lines are allowed (handled separately)
+ return;
+ }
+
+ int indentSize = options.indent();
+ int leadingSpaces = 0;
+
+ for (int i = 0; i < line.length(); i++) {
+ char c = line.charAt(i);
+ if (c == '\t') {
+ throw new IllegalArgumentException(
+ String.format("Tab character used in indentation at line %d", currentLine + 1));
+ } else if (c == ' ') {
+ leadingSpaces++;
+ } else {
+ // Reached non-whitespace
+ break;
+ }
+ }
+
+ // Check for non-multiple indentation (only if there's actual content)
+ if (leadingSpaces > 0 && leadingSpaces % indentSize != 0) {
+ throw new IllegalArgumentException(
+ String.format("Non-multiple indentation: %d spaces with indent=%d at line %d",
+ leadingSpaces, indentSize, currentLine + 1));
+ }
+ }
}
}
diff --git a/src/main/java/com/felipestanzani/jtoon/util/StringEscaper.java b/src/main/java/com/felipestanzani/jtoon/util/StringEscaper.java
index 72d99ee..0e24288 100644
--- a/src/main/java/com/felipestanzani/jtoon/util/StringEscaper.java
+++ b/src/main/java/com/felipestanzani/jtoon/util/StringEscaper.java
@@ -26,6 +26,53 @@ public static String escape(String value) {
.replace("\t", "\\t");
}
+ /**
+ * Validates a quoted string for invalid escape sequences and unterminated strings.
+ *
+ * @param value The string to validate
+ * @throws IllegalArgumentException if the string has invalid escape sequences or is unterminated
+ */
+ public static void validateString(String value) {
+ if (value == null || value.isEmpty()) {
+ return;
+ }
+
+ // Check for unterminated string (starts with quote but doesn't end with quote)
+ if (value.startsWith("\"") && !value.endsWith("\"")) {
+ throw new IllegalArgumentException("Unterminated string");
+ }
+
+ // Check for invalid escape sequences in quoted strings
+ if (value.startsWith("\"") && value.endsWith("\"")) {
+ String unquoted = value.substring(1, value.length() - 1);
+ boolean escaped = false;
+
+ for (char c : unquoted.toCharArray()) {
+ if (escaped) {
+ // Check if escape sequence is valid
+ if (!isValidEscapeChar(c)) {
+ throw new IllegalArgumentException("Invalid escape sequence: \\" + c);
+ }
+ escaped = false;
+ } else if (c == '\\') {
+ escaped = true;
+ }
+ }
+
+ // Check for trailing backslash (invalid escape)
+ if (escaped) {
+ throw new IllegalArgumentException("Invalid escape sequence: trailing backslash");
+ }
+ }
+ }
+
+ /**
+ * Checks if a character is a valid escape sequence.
+ */
+ private static boolean isValidEscapeChar(char c) {
+ return c == 'n' || c == 'r' || c == 't' || c == '"' || c == '\\';
+ }
+
/**
* Unescapes a string and removes surrounding quotes if present.
* Reverses the escaping applied by {@link #escape(String)}.
diff --git a/src/test/java/com/felipestanzani/jtoon/DecodeOptionsTest.java b/src/test/java/com/felipestanzani/jtoon/DecodeOptionsTest.java
index bfb3c6e..ff56e3a 100644
--- a/src/test/java/com/felipestanzani/jtoon/DecodeOptionsTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/DecodeOptionsTest.java
@@ -75,7 +75,7 @@ class CustomOptions {
@Test
@DisplayName("should create options with all custom values")
void testAllCustomValues() {
- DecodeOptions options = new DecodeOptions(4, Delimiter.TAB, false);
+ DecodeOptions options = new DecodeOptions(4, Delimiter.TAB, false, PathExpansion.OFF);
assertEquals(4, options.indent());
assertEquals(Delimiter.TAB, options.delimiter());
assertFalse(options.strict());
@@ -84,9 +84,9 @@ void testAllCustomValues() {
@Test
@DisplayName("should support all delimiter types")
void testAllDelimiters() {
- assertEquals(Delimiter.COMMA, new DecodeOptions(2, Delimiter.COMMA, true).delimiter());
- assertEquals(Delimiter.TAB, new DecodeOptions(2, Delimiter.TAB, true).delimiter());
- assertEquals(Delimiter.PIPE, new DecodeOptions(2, Delimiter.PIPE, true).delimiter());
+ assertEquals(Delimiter.COMMA, new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.OFF).delimiter());
+ assertEquals(Delimiter.TAB, new DecodeOptions(2, Delimiter.TAB, true, PathExpansion.OFF).delimiter());
+ assertEquals(Delimiter.PIPE, new DecodeOptions(2, Delimiter.PIPE, true, PathExpansion.OFF).delimiter());
}
}
@@ -97,8 +97,8 @@ class RecordBehavior {
@Test
@DisplayName("should be equal when values are equal")
void testEquality() {
- DecodeOptions options1 = new DecodeOptions(2, Delimiter.COMMA, true);
- DecodeOptions options2 = new DecodeOptions(2, Delimiter.COMMA, true);
+ DecodeOptions options1 = new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.OFF);
+ DecodeOptions options2 = new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.OFF);
assertEquals(options1, options2);
assertEquals(options1.hashCode(), options2.hashCode());
}
@@ -106,10 +106,10 @@ void testEquality() {
@Test
@DisplayName("should not be equal when values differ")
void testInequality() {
- DecodeOptions options1 = new DecodeOptions(2, Delimiter.COMMA, true);
- DecodeOptions options2 = new DecodeOptions(4, Delimiter.COMMA, true);
- DecodeOptions options3 = new DecodeOptions(2, Delimiter.PIPE, true);
- DecodeOptions options4 = new DecodeOptions(2, Delimiter.COMMA, false);
+ DecodeOptions options1 = new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.OFF);
+ DecodeOptions options2 = new DecodeOptions(4, Delimiter.COMMA, true, PathExpansion.OFF);
+ DecodeOptions options3 = new DecodeOptions(2, Delimiter.PIPE, true, PathExpansion.OFF);
+ DecodeOptions options4 = new DecodeOptions(2, Delimiter.COMMA, false, PathExpansion.OFF);
assertNotEquals(options1, options2);
assertNotEquals(options1, options3);
@@ -119,7 +119,7 @@ void testInequality() {
@Test
@DisplayName("should have meaningful toString")
void testToString() {
- DecodeOptions options = new DecodeOptions(4, Delimiter.TAB, false);
+ DecodeOptions options = new DecodeOptions(4, Delimiter.TAB, false, PathExpansion.OFF);
String str = options.toString();
assertTrue(str.contains("4"), "ToString should contain indent value: " + str);
assertTrue(str.contains("TAB") || str.contains("delimiter="), "ToString should contain delimiter: " + str);
diff --git a/src/test/java/com/felipestanzani/jtoon/JToonDecodeTest.java b/src/test/java/com/felipestanzani/jtoon/JToonDecodeTest.java
index d4a9c9e..014978b 100644
--- a/src/test/java/com/felipestanzani/jtoon/JToonDecodeTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/JToonDecodeTest.java
@@ -5,6 +5,7 @@
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -157,7 +158,7 @@ void testDeeplyNestedObject() {
id: 123
contact:
email: ada@example.com
- phone: \"555-1234\"
+ phone: "555-1234"
""";
Object result = JToon.decode(toon);
@SuppressWarnings("unchecked")
@@ -294,8 +295,8 @@ void testTabularArrayMixedTypes() {
void testTabularArrayQuotedValues() {
String toon = """
items[2]{id,name}:
- 1,\"First Item\"
- 2,\"Second, Item\"
+ 1,"First Item"
+ 2,"Second, Item"
""";
Object result = JToon.decode(toon);
@SuppressWarnings("unchecked")
@@ -471,9 +472,9 @@ class ErrorHandling {
@Test
@DisplayName("should handle empty input")
void testEmptyInput() {
- assertNull(JToon.decode(""));
- assertNull(JToon.decode(" "));
- assertNull(JToon.decode(null));
+ assertEquals(Collections.emptyMap(), JToon.decode(""));
+ assertEquals(Collections.emptyMap(), JToon.decode(" "));
+ assertEquals(Collections.emptyMap(), JToon.decode(null));
}
@Test
@@ -489,8 +490,8 @@ void testStrictModeError() {
void testLenientMode() {
String toon = "[invalid]"; // Invalid array header format
DecodeOptions options = DecodeOptions.withStrict(false);
- Object result = JToon.decode(toon, options);
- assertNull(result);
+ var result = JToon.decode(toon, options);
+ assertEquals(Collections.emptyList(), result);
}
}
diff --git a/src/test/java/com/felipestanzani/jtoon/RoundTripTest.java b/src/test/java/com/felipestanzani/jtoon/RoundTripTest.java
index b2773c7..81e52bb 100644
--- a/src/test/java/com/felipestanzani/jtoon/RoundTripTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/RoundTripTest.java
@@ -6,6 +6,7 @@
import org.junit.jupiter.api.Test;
import java.util.Arrays;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -309,7 +310,7 @@ void testTabDelimiterRoundTrip() {
data.put("tags", Arrays.asList("a", "b", "c"));
EncodeOptions encodeOpts = new EncodeOptions(2, Delimiter.TAB, false);
- DecodeOptions decodeOpts = new DecodeOptions(2, Delimiter.TAB, true);
+ DecodeOptions decodeOpts = new DecodeOptions(2, Delimiter.TAB, true, PathExpansion.OFF);
String toon = JToon.encode(data, encodeOpts);
Object decoded = JToon.decode(toon, decodeOpts);
@@ -324,7 +325,7 @@ void testPipeDelimiterRoundTrip() {
data.put("tags", Arrays.asList("a", "b", "c"));
EncodeOptions encodeOpts = new EncodeOptions(2, Delimiter.PIPE, false);
- DecodeOptions decodeOpts = new DecodeOptions(2, Delimiter.PIPE, true);
+ DecodeOptions decodeOpts = new DecodeOptions(2, Delimiter.PIPE, true, PathExpansion.OFF);
String toon = JToon.encode(data, encodeOpts);
Object decoded = JToon.decode(toon, decodeOpts);
@@ -372,8 +373,8 @@ void testEmptyObjectRoundTrip() {
String toon = JToon.encode(data);
Object decoded = JToon.decode(toon);
- // Empty object encodes to empty string, which decodes to null
- assertNull(decoded);
+ // Empty object encodes to empty string, which decodes to empty object
+ assertEquals(Collections.emptyMap(), decoded);
}
@Test
diff --git a/src/test/java/com/felipestanzani/jtoon/conformance/ConformanceTest.java b/src/test/java/com/felipestanzani/jtoon/conformance/ConformanceTest.java
new file mode 100644
index 0000000..52fbd07
--- /dev/null
+++ b/src/test/java/com/felipestanzani/jtoon/conformance/ConformanceTest.java
@@ -0,0 +1,214 @@
+package com.felipestanzani.jtoon.conformance;
+
+import com.felipestanzani.jtoon.DecodeOptions;
+import com.felipestanzani.jtoon.Delimiter;
+import com.felipestanzani.jtoon.EncodeOptions;
+import com.felipestanzani.jtoon.JToon;
+import com.felipestanzani.jtoon.PathExpansion;
+import com.felipestanzani.jtoon.conformance.model.*;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DynamicContainer;
+import org.junit.jupiter.api.DynamicNode;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.TestFactory;
+import tools.jackson.databind.ObjectMapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+@Tag("unit")
+public class ConformanceTest {
+ @Nested
+ @DisplayName("Encoding conformance tests")
+ class encodeJsonTest {
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @TestFactory
+ Stream testJSONFile() {
+ File directory = new File("src/test/resources/conformance/encode");
+ return loadTestFixtures(directory)
+ .map(this::createTestContainer);
+ }
+
+ private Stream loadTestFixtures(File directory) {
+ File[] files = Objects.requireNonNull(directory.listFiles());
+ return Arrays.stream(files)
+ .map(this::parseFixture);
+ }
+
+ private EncodeTestFile parseFixture(File file) {
+ try {
+ EncodeTestFixture fixture = mapper.readValue(file, EncodeTestFixture.class);
+ return new EncodeTestFile(file, fixture);
+ } catch (Exception exception) {
+ throw new RuntimeException("Failed to parse test fixture: " + file.getName(), exception);
+ }
+ }
+
+ private DynamicContainer createTestContainer(EncodeTestFile encodeFile) {
+ File file = encodeFile.file();
+ Stream tests = createTestsFromFixture(encodeFile);
+
+ return DynamicContainer.dynamicContainer(
+ file.getName(),
+ tests);
+ }
+
+ private Stream createTestsFromFixture(EncodeTestFile encodeFile) {
+ EncodeTestFixture fixture = encodeFile.fixture();
+ return fixture.tests().stream()
+ .map(this::createDynamicTest);
+ }
+
+ private DynamicTest createDynamicTest(JsonEncodeTestCase testCase) {
+ return DynamicTest.dynamicTest(testCase.name(), () -> executeTestCase(testCase));
+ }
+
+ private void executeTestCase(JsonEncodeTestCase testCase) {
+ EncodeOptions options = parseOptions(testCase.options());
+ String jsonInput = mapper.writeValueAsString(testCase.input());
+ String actual = JToon.encodeJson(jsonInput, options);
+ assertEquals(testCase.expected(), actual);
+ }
+
+ private EncodeOptions parseOptions(JsonEncodeTestOptions options) {
+ if (options == null) {
+ return EncodeOptions.DEFAULT;
+ }
+
+ int indent = options.indent() != null ? options.indent() : 2;
+
+ Delimiter delimiter = Delimiter.COMMA;
+ if (options.delimiter() != null) {
+ String delimiterValue = options.delimiter();
+ delimiter = switch (delimiterValue) {
+ case "\t" -> Delimiter.TAB;
+ case "|" -> Delimiter.PIPE;
+ case "," -> Delimiter.COMMA;
+ default -> delimiter;
+ };
+ }
+
+ boolean lengthMarker = options.lengthMarker() != null && "#".equals(options.lengthMarker());
+
+ return new EncodeOptions(indent, delimiter, lengthMarker);
+ }
+
+ private record EncodeTestFile(File file, EncodeTestFixture fixture) {
+ }
+ }
+
+ @Nested
+ @DisplayName("Decoding conformance tests")
+ class decodeJsonTest {
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @TestFactory
+ Stream testJSONFile() {
+ File directory = new File("src/test/resources/conformance/decode");
+ return loadTestFixtures(directory)
+ .map(this::createTestContainer);
+ }
+
+ private Stream loadTestFixtures(File directory) {
+ File[] files = Objects.requireNonNull(directory.listFiles());
+ return Arrays.stream(files)
+ .map(this::parseFixture);
+ }
+
+ private DecodeTestFile parseFixture(File file) {
+ try {
+ var fixture = mapper.readValue(file, DecodeTestFixture.class);
+ return new DecodeTestFile(file, fixture);
+ } catch (Exception exception) {
+ throw new RuntimeException("Failed to parse test fixture: " + file.getName(), exception);
+ }
+ }
+
+ private DynamicContainer createTestContainer(DecodeTestFile decodeFile) {
+ File file = decodeFile.file();
+ Stream tests = createTestsFromFixture(decodeFile);
+
+ return DynamicContainer.dynamicContainer(
+ file.getName(),
+ tests);
+ }
+
+ private Stream createTestsFromFixture(DecodeTestFile decodeFile) {
+ var fixture = decodeFile.fixture();
+ return fixture.tests().stream()
+ .map(this::createDynamicTest);
+ }
+
+ private DynamicTest createDynamicTest(JsonDecodeTestCase testCase) {
+ return DynamicTest.dynamicTest(testCase.name(), () -> executeTestCase(testCase));
+ }
+
+ private void executeTestCase(JsonDecodeTestCase testCase) {
+ var options = parseOptions(testCase.options());
+ String toonInput = testCase.input().asString();
+
+ if (Boolean.TRUE.equals(testCase.shouldError())) {
+ Object actual;
+ try {
+ actual = JToon.decode(toonInput, options);
+ } catch (IllegalArgumentException e) {
+ return;
+ }
+ String actualJson = mapper.writeValueAsString(actual);
+ fail("Expected IllegalArgumentException but got result: " + actualJson);
+ } else {
+ Object actual = JToon.decode(toonInput, options);
+ if (testCase.expected() == null || testCase.expected().isNull()) {
+ assertNull(actual, "Expected null but got: " + actual);
+ } else {
+ String actualJson = mapper.writeValueAsString(actual);
+ String expectedJson = mapper.writeValueAsString(testCase.expected());
+ assertEquals(expectedJson, actualJson);
+ }
+ }
+ }
+
+ private DecodeOptions parseOptions(JsonDecodeTestOptions options) {
+ if (options == null) {
+ return DecodeOptions.DEFAULT;
+ }
+
+ int indent = options.indent() != null ? options.indent() : 2;
+
+ Delimiter delimiter = Delimiter.COMMA;
+ if (options.delimiter() != null) {
+ String delimiterValue = options.delimiter();
+ delimiter = switch (delimiterValue) {
+ case "\t" -> Delimiter.TAB;
+ case "|" -> Delimiter.PIPE;
+ case "," -> Delimiter.COMMA;
+ default -> delimiter;
+ };
+ }
+
+ boolean strict = options.strict() != null ? options.strict() : true;
+
+ PathExpansion expandPaths = null;
+ if (options.expandPaths() != null) {
+ expandPaths = switch (options.expandPaths().toLowerCase()) {
+ case "safe" -> PathExpansion.SAFE;
+ default -> PathExpansion.OFF;
+ };
+ }
+
+ return new DecodeOptions(indent, delimiter, strict, expandPaths);
+ }
+
+ private record DecodeTestFile(File file, DecodeTestFixture fixture) {
+ }
+ }
+}
diff --git a/src/test/java/com/felipestanzani/jtoon/conformance/model/DecodeTestFixture.java b/src/test/java/com/felipestanzani/jtoon/conformance/model/DecodeTestFixture.java
new file mode 100644
index 0000000..35d1dd8
--- /dev/null
+++ b/src/test/java/com/felipestanzani/jtoon/conformance/model/DecodeTestFixture.java
@@ -0,0 +1,9 @@
+package com.felipestanzani.jtoon.conformance.model;
+
+import java.util.List;
+
+public record DecodeTestFixture(String version,
+ String category,
+ String description,
+ List tests) {
+}
diff --git a/src/test/java/com/felipestanzani/jtoon/conformance/model/EncodeTestFixture.java b/src/test/java/com/felipestanzani/jtoon/conformance/model/EncodeTestFixture.java
new file mode 100644
index 0000000..ecf3cb6
--- /dev/null
+++ b/src/test/java/com/felipestanzani/jtoon/conformance/model/EncodeTestFixture.java
@@ -0,0 +1,9 @@
+package com.felipestanzani.jtoon.conformance.model;
+
+import java.util.List;
+
+public record EncodeTestFixture(String version,
+ String category,
+ String description,
+ List tests) {
+}
diff --git a/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonDecodeTestCase.java b/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonDecodeTestCase.java
new file mode 100644
index 0000000..ac95723
--- /dev/null
+++ b/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonDecodeTestCase.java
@@ -0,0 +1,14 @@
+package com.felipestanzani.jtoon.conformance.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.databind.JsonNode;
+
+public record JsonDecodeTestCase(String name,
+ JsonNode input,
+ JsonNode expected,
+ String specSection,
+ @JsonIgnore String note,
+ JsonDecodeTestOptions options,
+ @JsonProperty("shouldError") Boolean shouldError) {
+}
diff --git a/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonDecodeTestOptions.java b/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonDecodeTestOptions.java
new file mode 100644
index 0000000..bd826e8
--- /dev/null
+++ b/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonDecodeTestOptions.java
@@ -0,0 +1,10 @@
+package com.felipestanzani.jtoon.conformance.model;
+
+public record JsonDecodeTestOptions(
+ Integer indent,
+ String delimiter,
+ String lengthMarker,
+ Boolean strict,
+ String expandPaths) {
+}
+
diff --git a/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonEncodeTestCase.java b/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonEncodeTestCase.java
new file mode 100644
index 0000000..1a0beb1
--- /dev/null
+++ b/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonEncodeTestCase.java
@@ -0,0 +1,13 @@
+package com.felipestanzani.jtoon.conformance.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.databind.JsonNode;
+
+public record JsonEncodeTestCase(String name,
+ JsonNode input,
+ String expected,
+ @JsonProperty("specSection") String spec,
+ @JsonIgnore String note,
+ JsonEncodeTestOptions options) {
+}
diff --git a/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonEncodeTestOptions.java b/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonEncodeTestOptions.java
new file mode 100644
index 0000000..912e5ea
--- /dev/null
+++ b/src/test/java/com/felipestanzani/jtoon/conformance/model/JsonEncodeTestOptions.java
@@ -0,0 +1,8 @@
+package com.felipestanzani.jtoon.conformance.model;
+
+public record JsonEncodeTestOptions(
+ Integer indent,
+ String delimiter,
+ String lengthMarker) {
+}
+
diff --git a/src/test/java/com/felipestanzani/jtoon/HeaderFormatterTest.java b/src/test/java/com/felipestanzani/jtoon/encoder/HeaderFormatterTest.java
similarity index 99%
rename from src/test/java/com/felipestanzani/jtoon/HeaderFormatterTest.java
rename to src/test/java/com/felipestanzani/jtoon/encoder/HeaderFormatterTest.java
index 461c399..fe0cefa 100644
--- a/src/test/java/com/felipestanzani/jtoon/HeaderFormatterTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/encoder/HeaderFormatterTest.java
@@ -1,6 +1,5 @@
-package com.felipestanzani.jtoon;
+package com.felipestanzani.jtoon.encoder;
-import com.felipestanzani.jtoon.encoder.HeaderFormatter;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
diff --git a/src/test/java/com/felipestanzani/jtoon/LineWriterTest.java b/src/test/java/com/felipestanzani/jtoon/encoder/LineWriterTest.java
similarity index 99%
rename from src/test/java/com/felipestanzani/jtoon/LineWriterTest.java
rename to src/test/java/com/felipestanzani/jtoon/encoder/LineWriterTest.java
index 8e9ecdd..9cf12f2 100644
--- a/src/test/java/com/felipestanzani/jtoon/LineWriterTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/encoder/LineWriterTest.java
@@ -1,6 +1,5 @@
-package com.felipestanzani.jtoon;
+package com.felipestanzani.jtoon.encoder;
-import com.felipestanzani.jtoon.encoder.LineWriter;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
diff --git a/src/test/java/com/felipestanzani/jtoon/PrimitiveEncoderTest.java b/src/test/java/com/felipestanzani/jtoon/encoder/PrimitiveEncoderTest.java
similarity index 99%
rename from src/test/java/com/felipestanzani/jtoon/PrimitiveEncoderTest.java
rename to src/test/java/com/felipestanzani/jtoon/encoder/PrimitiveEncoderTest.java
index a0abefb..e7bf317 100644
--- a/src/test/java/com/felipestanzani/jtoon/PrimitiveEncoderTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/encoder/PrimitiveEncoderTest.java
@@ -1,6 +1,5 @@
-package com.felipestanzani.jtoon;
+package com.felipestanzani.jtoon.encoder;
-import com.felipestanzani.jtoon.encoder.PrimitiveEncoder;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
diff --git a/src/test/java/com/felipestanzani/jtoon/JsonNormalizerTest.java b/src/test/java/com/felipestanzani/jtoon/normalizer/JsonNormalizerTest.java
similarity index 99%
rename from src/test/java/com/felipestanzani/jtoon/JsonNormalizerTest.java
rename to src/test/java/com/felipestanzani/jtoon/normalizer/JsonNormalizerTest.java
index 0617456..2875dbe 100644
--- a/src/test/java/com/felipestanzani/jtoon/JsonNormalizerTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/normalizer/JsonNormalizerTest.java
@@ -1,6 +1,5 @@
-package com.felipestanzani.jtoon;
+package com.felipestanzani.jtoon.normalizer;
-import com.felipestanzani.jtoon.normalizer.JsonNormalizer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
diff --git a/src/test/java/com/felipestanzani/jtoon/StringEscaperTest.java b/src/test/java/com/felipestanzani/jtoon/util/StringEscaperTest.java
similarity index 98%
rename from src/test/java/com/felipestanzani/jtoon/StringEscaperTest.java
rename to src/test/java/com/felipestanzani/jtoon/util/StringEscaperTest.java
index 0a00e02..e24f43e 100644
--- a/src/test/java/com/felipestanzani/jtoon/StringEscaperTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/util/StringEscaperTest.java
@@ -1,6 +1,5 @@
-package com.felipestanzani.jtoon;
+package com.felipestanzani.jtoon.util;
-import com.felipestanzani.jtoon.util.StringEscaper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
diff --git a/src/test/java/com/felipestanzani/jtoon/StringValidatorTest.java b/src/test/java/com/felipestanzani/jtoon/util/StringValidatorTest.java
similarity index 99%
rename from src/test/java/com/felipestanzani/jtoon/StringValidatorTest.java
rename to src/test/java/com/felipestanzani/jtoon/util/StringValidatorTest.java
index e2ae834..9b4d1bd 100644
--- a/src/test/java/com/felipestanzani/jtoon/StringValidatorTest.java
+++ b/src/test/java/com/felipestanzani/jtoon/util/StringValidatorTest.java
@@ -1,6 +1,5 @@
-package com.felipestanzani.jtoon;
+package com.felipestanzani.jtoon.util;
-import com.felipestanzani.jtoon.util.StringValidator;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
diff --git a/src/test/resources/conformance/decode/arrays-nested.json b/src/test/resources/conformance/decode/arrays-nested.json
new file mode 100644
index 0000000..32b90fb
--- /dev/null
+++ b/src/test/resources/conformance/decode/arrays-nested.json
@@ -0,0 +1,194 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Nested and mixed array decoding - list format, arrays of arrays, root arrays, mixed types",
+ "tests": [
+ {
+ "name": "parses list arrays for non-uniform objects",
+ "input": "items[2]:\n - id: 1\n name: First\n - id: 2\n name: Second\n extra: true",
+ "expected": {
+ "items": [
+ { "id": 1, "name": "First" },
+ { "id": 2, "name": "Second", "extra": true }
+ ]
+ },
+ "specSection": "9.4"
+ },
+ {
+ "name": "parses list arrays with empty items",
+ "input": "items[3]:\n - first\n - second\n -",
+ "expected": {
+ "items": ["first", "second", {}]
+ },
+ "specSection": "9.4"
+ },
+ {
+ "name": "parses list arrays with deeply nested objects",
+ "input": "items[2]:\n - properties:\n state:\n type: string\n - id: 2",
+ "expected": {
+ "items": [
+ {
+ "properties": {
+ "state": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "id": 2
+ }
+ ]
+ },
+ "specSection": "10"
+ },
+ {
+ "name": "parses list arrays containing objects with nested properties",
+ "input": "items[1]:\n - id: 1\n nested:\n x: 1",
+ "expected": {
+ "items": [
+ { "id": 1, "nested": { "x": 1 } }
+ ]
+ },
+ "specSection": "9.4"
+ },
+ {
+ "name": "parses nested tabular arrays as first field on hyphen line",
+ "input": "items[1]:\n - users[2]{id,name}:\n 1,Ada\n 2,Bob\n status: active",
+ "expected": {
+ "items": [
+ {
+ "users": [
+ { "id": 1, "name": "Ada" },
+ { "id": 2, "name": "Bob" }
+ ],
+ "status": "active"
+ }
+ ]
+ },
+ "specSection": "10"
+ },
+ {
+ "name": "parses objects containing arrays (including empty arrays) in list format",
+ "input": "items[1]:\n - name: test\n data[0]:",
+ "expected": {
+ "items": [
+ { "name": "test", "data": [] }
+ ]
+ },
+ "specSection": "9.4"
+ },
+ {
+ "name": "parses arrays of arrays within objects",
+ "input": "items[1]:\n - matrix[2]:\n - [2]: 1,2\n - [2]: 3,4\n name: grid",
+ "expected": {
+ "items": [
+ { "matrix": [[1, 2], [3, 4]], "name": "grid" }
+ ]
+ },
+ "specSection": "9.2"
+ },
+ {
+ "name": "parses nested arrays of primitives",
+ "input": "pairs[2]:\n - [2]: a,b\n - [2]: c,d",
+ "expected": {
+ "pairs": [["a", "b"], ["c", "d"]]
+ },
+ "specSection": "9.2"
+ },
+ {
+ "name": "parses quoted strings and mixed lengths in nested arrays",
+ "input": "pairs[2]:\n - [2]: a,b\n - [3]: \"c,d\",\"e:f\",\"true\"",
+ "expected": {
+ "pairs": [["a", "b"], ["c,d", "e:f", "true"]]
+ },
+ "specSection": "9.2"
+ },
+ {
+ "name": "parses empty inner arrays",
+ "input": "pairs[2]:\n - [0]:\n - [0]:",
+ "expected": {
+ "pairs": [[], []]
+ },
+ "specSection": "9.2"
+ },
+ {
+ "name": "parses mixed-length inner arrays",
+ "input": "pairs[2]:\n - [1]: 1\n - [2]: 2,3",
+ "expected": {
+ "pairs": [[1], [2, 3]]
+ },
+ "specSection": "9.2"
+ },
+ {
+ "name": "parses root arrays of primitives (inline)",
+ "input": "[5]: x,y,\"true\",true,10",
+ "expected": ["x", "y", "true", true, 10],
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses root arrays of uniform objects in tabular format",
+ "input": "[2]{id}:\n 1\n 2",
+ "expected": [{ "id": 1 }, { "id": 2 }],
+ "specSection": "9.3"
+ },
+ {
+ "name": "parses root arrays of non-uniform objects in list format",
+ "input": "[2]:\n - id: 1\n - id: 2\n name: Ada",
+ "expected": [{ "id": 1 }, { "id": 2, "name": "Ada" }],
+ "specSection": "9.4"
+ },
+ {
+ "name": "parses empty root arrays",
+ "input": "[0]:",
+ "expected": [],
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses root arrays of arrays",
+ "input": "[2]:\n - [2]: 1,2\n - [0]:",
+ "expected": [[1, 2], []],
+ "specSection": "9.2"
+ },
+ {
+ "name": "parses complex mixed object with arrays and nested objects",
+ "input": "user:\n id: 123\n name: Ada\n tags[2]: reading,gaming\n active: true\n prefs[0]:",
+ "expected": {
+ "user": {
+ "id": 123,
+ "name": "Ada",
+ "tags": ["reading", "gaming"],
+ "active": true,
+ "prefs": []
+ }
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses arrays mixing primitives, objects and strings (list format)",
+ "input": "items[3]:\n - 1\n - a: 1\n - text",
+ "expected": {
+ "items": [1, { "a": 1 }, "text"]
+ },
+ "specSection": "9.4"
+ },
+ {
+ "name": "parses arrays mixing objects and arrays",
+ "input": "items[2]:\n - a: 1\n - [2]: 1,2",
+ "expected": {
+ "items": [{ "a": 1 }, [1, 2]]
+ },
+ "specSection": "9.4"
+ },
+ {
+ "name": "parses quoted key with list array format",
+ "input": "\"x-items\"[2]:\n - id: 1\n - id: 2",
+ "expected": {
+ "x-items": [
+ { "id": 1 },
+ { "id": 2 }
+ ]
+ },
+ "specSection": "9.4"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/arrays-primitive.json b/src/test/resources/conformance/decode/arrays-primitive.json
new file mode 100644
index 0000000..823cbd9
--- /dev/null
+++ b/src/test/resources/conformance/decode/arrays-primitive.json
@@ -0,0 +1,111 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Primitive array decoding - inline arrays of strings, numbers, booleans, quoted strings",
+ "tests": [
+ {
+ "name": "parses string arrays inline",
+ "input": "tags[3]: reading,gaming,coding",
+ "expected": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses number arrays inline",
+ "input": "nums[3]: 1,2,3",
+ "expected": {
+ "nums": [1, 2, 3]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses mixed primitive arrays inline",
+ "input": "data[4]: x,y,true,10",
+ "expected": {
+ "data": ["x", "y", true, 10]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses empty arrays",
+ "input": "items[0]:",
+ "expected": {
+ "items": []
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses single-item array with empty string",
+ "input": "items[1]: \"\"",
+ "expected": {
+ "items": [""]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses multi-item array with empty string",
+ "input": "items[3]: a,\"\",b",
+ "expected": {
+ "items": ["a", "", "b"]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses whitespace-only strings in arrays",
+ "input": "items[2]: \" \",\" \"",
+ "expected": {
+ "items": [" ", " "]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses strings with delimiters in arrays",
+ "input": "items[3]: a,\"b,c\",\"d:e\"",
+ "expected": {
+ "items": ["a", "b,c", "d:e"]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses strings that look like primitives when quoted",
+ "input": "items[4]: x,\"true\",\"42\",\"-3.14\"",
+ "expected": {
+ "items": ["x", "true", "42", "-3.14"]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses strings with structural tokens in arrays",
+ "input": "items[3]: \"[5]\",\"- item\",\"{key}\"",
+ "expected": {
+ "items": ["[5]", "- item", "{key}"]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses quoted key with inline array",
+ "input": "\"my-key\"[3]: 1,2,3",
+ "expected": {
+ "my-key": [1, 2, 3]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses quoted key containing brackets with inline array",
+ "input": "\"key[test]\"[3]: 1,2,3",
+ "expected": {
+ "key[test]": [1, 2, 3]
+ },
+ "specSection": "9.1"
+ },
+ {
+ "name": "parses quoted key with empty array",
+ "input": "\"x-custom\"[0]:",
+ "expected": {
+ "x-custom": []
+ },
+ "specSection": "9.1"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/arrays-tabular.json b/src/test/resources/conformance/decode/arrays-tabular.json
new file mode 100644
index 0000000..34b08bc
--- /dev/null
+++ b/src/test/resources/conformance/decode/arrays-tabular.json
@@ -0,0 +1,74 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Tabular array decoding - parsing arrays of uniform objects with headers",
+ "tests": [
+ {
+ "name": "parses tabular arrays of uniform objects",
+ "input": "items[2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5",
+ "expected": {
+ "items": [
+ { "sku": "A1", "qty": 2, "price": 9.99 },
+ { "sku": "B2", "qty": 1, "price": 14.5 }
+ ]
+ },
+ "specSection": "9.3"
+ },
+ {
+ "name": "parses nulls and quoted values in tabular rows",
+ "input": "items[2]{id,value}:\n 1,null\n 2,\"test\"",
+ "expected": {
+ "items": [
+ { "id": 1, "value": null },
+ { "id": 2, "value": "test" }
+ ]
+ },
+ "specSection": "9.3"
+ },
+ {
+ "name": "parses quoted colon in tabular row as data",
+ "input": "items[2]{id,note}:\n 1,\"a:b\"\n 2,\"c:d\"",
+ "expected": {
+ "items": [
+ { "id": 1, "note": "a:b" },
+ { "id": 2, "note": "c:d" }
+ ]
+ },
+ "specSection": "9.3"
+ },
+ {
+ "name": "parses quoted header keys in tabular arrays",
+ "input": "items[2]{\"order:id\",\"full name\"}:\n 1,Ada\n 2,Bob",
+ "expected": {
+ "items": [
+ { "order:id": 1, "full name": "Ada" },
+ { "order:id": 2, "full name": "Bob" }
+ ]
+ },
+ "specSection": "9.3"
+ },
+ {
+ "name": "parses quoted key with tabular array format",
+ "input": "\"x-items\"[2]{id,name}:\n 1,Ada\n 2,Bob",
+ "expected": {
+ "x-items": [
+ { "id": 1, "name": "Ada" },
+ { "id": 2, "name": "Bob" }
+ ]
+ },
+ "specSection": "9.3"
+ },
+ {
+ "name": "unquoted colon terminates tabular rows and starts key-value pair",
+ "input": "items[2]{id,name}:\n 1,Alice\n 2,Bob\ncount: 2",
+ "expected": {
+ "items": [
+ { "id": 1, "name": "Alice" },
+ { "id": 2, "name": "Bob" }
+ ],
+ "count": 2
+ },
+ "specSection": "9.3"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/blank-lines.json b/src/test/resources/conformance/decode/blank-lines.json
new file mode 100644
index 0000000..dd217a3
--- /dev/null
+++ b/src/test/resources/conformance/decode/blank-lines.json
@@ -0,0 +1,153 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Blank line handling - strict mode errors on blank lines inside arrays, accepts blank lines outside arrays",
+ "tests": [
+ {
+ "name": "throws on blank line inside list array",
+ "input": "items[3]:\n - a\n\n - b\n - c",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "14.4"
+ },
+ {
+ "name": "throws on blank line inside tabular array",
+ "input": "items[2]{id}:\n 1\n\n 2",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "14.4"
+ },
+ {
+ "name": "throws on multiple blank lines inside array",
+ "input": "items[2]:\n - a\n\n\n - b",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "14.4"
+ },
+ {
+ "name": "throws on blank line with spaces inside array",
+ "input": "items[2]:\n - a\n \n - b",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "14.4"
+ },
+ {
+ "name": "throws on blank line in nested list array",
+ "input": "outer[2]:\n - inner[2]:\n - a\n\n - b\n - x",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "14.4"
+ },
+ {
+ "name": "accepts blank line between root-level fields",
+ "input": "a: 1\n\nb: 2",
+ "expected": {
+ "a": 1,
+ "b": 2
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "accepts trailing newline at end of file",
+ "input": "a: 1\n",
+ "expected": {
+ "a": 1
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "accepts multiple trailing newlines",
+ "input": "a: 1\n\n\n",
+ "expected": {
+ "a": 1
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "accepts blank line after array ends",
+ "input": "items[1]:\n - a\n\nb: 2",
+ "expected": {
+ "items": ["a"],
+ "b": 2
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "accepts blank line between nested object fields",
+ "input": "a:\n b: 1\n\n c: 2",
+ "expected": {
+ "a": {
+ "b": 1,
+ "c": 2
+ }
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "ignores blank lines inside list array when strict=false",
+ "input": "items[3]:\n - a\n\n - b\n - c",
+ "expected": {
+ "items": ["a", "b", "c"]
+ },
+ "options": {
+ "strict": false
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "ignores blank lines inside tabular array when strict=false",
+ "input": "items[2]{id,name}:\n 1,Alice\n\n 2,Bob",
+ "expected": {
+ "items": [
+ { "id": 1, "name": "Alice" },
+ { "id": 2, "name": "Bob" }
+ ]
+ },
+ "options": {
+ "strict": false
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "ignores multiple blank lines in arrays when strict=false",
+ "input": "items[2]:\n - a\n\n\n - b",
+ "expected": {
+ "items": ["a", "b"]
+ },
+ "options": {
+ "strict": false
+ },
+ "specSection": "12"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/delimiters.json b/src/test/resources/conformance/decode/delimiters.json
new file mode 100644
index 0000000..b28f014
--- /dev/null
+++ b/src/test/resources/conformance/decode/delimiters.json
@@ -0,0 +1,246 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Delimiter decoding - tab and pipe delimiter parsing, delimiter-aware value splitting",
+ "tests": [
+ {
+ "name": "parses primitive arrays with tab delimiter",
+ "input": "tags[3\t]: reading\tgaming\tcoding",
+ "expected": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses primitive arrays with pipe delimiter",
+ "input": "tags[3|]: reading|gaming|coding",
+ "expected": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses primitive arrays with comma delimiter",
+ "input": "tags[3]: reading,gaming,coding",
+ "expected": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses tabular arrays with tab delimiter",
+ "input": "items[2\t]{sku\tqty\tprice}:\n A1\t2\t9.99\n B2\t1\t14.5",
+ "expected": {
+ "items": [
+ { "sku": "A1", "qty": 2, "price": 9.99 },
+ { "sku": "B2", "qty": 1, "price": 14.5 }
+ ]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses tabular arrays with pipe delimiter",
+ "input": "items[2|]{sku|qty|price}:\n A1|2|9.99\n B2|1|14.5",
+ "expected": {
+ "items": [
+ { "sku": "A1", "qty": 2, "price": 9.99 },
+ { "sku": "B2", "qty": 1, "price": 14.5 }
+ ]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses nested arrays with tab delimiter",
+ "input": "pairs[2\t]:\n - [2\t]: a\tb\n - [2\t]: c\td",
+ "expected": {
+ "pairs": [["a", "b"], ["c", "d"]]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses nested arrays with pipe delimiter",
+ "input": "pairs[2|]:\n - [2|]: a|b\n - [2|]: c|d",
+ "expected": {
+ "pairs": [["a", "b"], ["c", "d"]]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "nested arrays inside list items default to comma delimiter",
+ "input": "items[1\t]:\n - tags[3]: a,b,c",
+ "expected": {
+ "items": [{ "tags": ["a", "b", "c"] }]
+ },
+ "specSection": "11",
+ "note": "Parent uses tab, nested defaults to comma"
+ },
+ {
+ "name": "nested arrays inside list items default to comma with pipe parent",
+ "input": "items[1|]:\n - tags[3]: a,b,c",
+ "expected": {
+ "items": [{ "tags": ["a", "b", "c"] }]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses root arrays with tab delimiter",
+ "input": "[3\t]: x\ty\tz",
+ "expected": ["x", "y", "z"],
+ "specSection": "11"
+ },
+ {
+ "name": "parses root arrays with pipe delimiter",
+ "input": "[3|]: x|y|z",
+ "expected": ["x", "y", "z"],
+ "specSection": "11"
+ },
+ {
+ "name": "parses root arrays of objects with tab delimiter",
+ "input": "[2\t]{id}:\n 1\n 2",
+ "expected": [{ "id": 1 }, { "id": 2 }],
+ "specSection": "11"
+ },
+ {
+ "name": "parses root arrays of objects with pipe delimiter",
+ "input": "[2|]{id}:\n 1\n 2",
+ "expected": [{ "id": 1 }, { "id": 2 }],
+ "specSection": "11"
+ },
+ {
+ "name": "parses values containing tab delimiter when quoted",
+ "input": "items[3\t]: a\t\"b\\tc\"\td",
+ "expected": {
+ "items": ["a", "b\tc", "d"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses values containing pipe delimiter when quoted",
+ "input": "items[3|]: a|\"b|c\"|d",
+ "expected": {
+ "items": ["a", "b|c", "d"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not split on commas when using tab delimiter",
+ "input": "items[2\t]: a,b\tc,d",
+ "expected": {
+ "items": ["a,b", "c,d"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not split on commas when using pipe delimiter",
+ "input": "items[2|]: a,b|c,d",
+ "expected": {
+ "items": ["a,b", "c,d"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses tabular values containing comma with comma delimiter",
+ "input": "items[2]{id,note}:\n 1,\"a,b\"\n 2,\"c,d\"",
+ "expected": {
+ "items": [
+ { "id": 1, "note": "a,b" },
+ { "id": 2, "note": "c,d" }
+ ]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not require quoting commas with tab delimiter",
+ "input": "items[2\t]{id\tnote}:\n 1\ta,b\n 2\tc,d",
+ "expected": {
+ "items": [
+ { "id": 1, "note": "a,b" },
+ { "id": 2, "note": "c,d" }
+ ]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not require quoting commas in object values",
+ "input": "note: a,b",
+ "expected": {
+ "note": "a,b"
+ },
+ "specSection": "11",
+ "note": "Object values don't require comma quoting regardless of delimiter"
+ },
+ {
+ "name": "object values in list items follow document delimiter",
+ "input": "items[2\t]:\n - status: a,b\n - status: c,d",
+ "expected": {
+ "items": [{ "status": "a,b" }, { "status": "c,d" }]
+ },
+ "specSection": "11",
+ "note": "Active delimiter is tab, but object values use document delimiter for quoting"
+ },
+ {
+ "name": "object values with comma must be quoted when document delimiter is comma",
+ "input": "items[2]:\n - status: \"a,b\"\n - status: \"c,d\"",
+ "expected": {
+ "items": [{ "status": "a,b" }, { "status": "c,d" }]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses nested array values containing pipe delimiter",
+ "input": "pairs[1|]:\n - [2|]: a|\"b|c\"",
+ "expected": {
+ "pairs": [["a", "b|c"]]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses nested array values containing tab delimiter",
+ "input": "pairs[1\t]:\n - [2\t]: a\t\"b\\tc\"",
+ "expected": {
+ "pairs": [["a", "b\tc"]]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "preserves quoted ambiguity with pipe delimiter",
+ "input": "items[3|]: \"true\"|\"42\"|\"-3.14\"",
+ "expected": {
+ "items": ["true", "42", "-3.14"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "preserves quoted ambiguity with tab delimiter",
+ "input": "items[3\t]: \"true\"\t\"42\"\t\"-3.14\"",
+ "expected": {
+ "items": ["true", "42", "-3.14"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses structural-looking strings when quoted with pipe delimiter",
+ "input": "items[3|]: \"[5]\"|\"{key}\"|\"- item\"",
+ "expected": {
+ "items": ["[5]", "{key}", "- item"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses structural-looking strings when quoted with tab delimiter",
+ "input": "items[3\t]: \"[5]\"\t\"{key}\"\t\"- item\"",
+ "expected": {
+ "items": ["[5]", "{key}", "- item"]
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "parses tabular headers with keys containing the active delimiter",
+ "input": "items[2|]{\"a|b\"}:\n 1\n 2",
+ "expected": {
+ "items": [{ "a|b": 1 }, { "a|b": 2 }]
+ },
+ "specSection": "11"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/indentation-errors.json b/src/test/resources/conformance/decode/indentation-errors.json
new file mode 100644
index 0000000..376f7f3
--- /dev/null
+++ b/src/test/resources/conformance/decode/indentation-errors.json
@@ -0,0 +1,184 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Strict mode indentation validation - non-multiple indentation, tab characters, custom indent sizes",
+ "tests": [
+ {
+ "name": "throws when object field has non-multiple indentation (3 spaces with indent=2)",
+ "input": "a:\n b: 1",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "indent": 2,
+ "strict": true
+ },
+ "specSection": "14.3"
+ },
+ {
+ "name": "throws when list item has non-multiple indentation (3 spaces with indent=2)",
+ "input": "items[2]:\n - id: 1\n - id: 2",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "indent": 2,
+ "strict": true
+ },
+ "specSection": "14.3"
+ },
+ {
+ "name": "throws with custom indent size when non-multiple (3 spaces with indent=4)",
+ "input": "a:\n b: 1",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "indent": 4,
+ "strict": true
+ },
+ "specSection": "14.3"
+ },
+ {
+ "name": "accepts correct indentation with custom indent size (4 spaces with indent=4)",
+ "input": "a:\n b: 1",
+ "expected": {
+ "a": {
+ "b": 1
+ }
+ },
+ "options": {
+ "indent": 4,
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "throws when tab character used in indentation",
+ "input": "a:\n\tb: 1",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "14.3"
+ },
+ {
+ "name": "throws when mixed tabs and spaces in indentation",
+ "input": "a:\n \tb: 1",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "14.3"
+ },
+ {
+ "name": "throws when tab at start of line",
+ "input": "\ta: 1",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "14.3"
+ },
+ {
+ "name": "accepts tabs in quoted string values",
+ "input": "text: \"hello\tworld\"",
+ "expected": {
+ "text": "hello\tworld"
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "accepts tabs in quoted keys",
+ "input": "\"key\ttab\": value",
+ "expected": {
+ "key\ttab": "value"
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "accepts tabs in quoted array elements",
+ "input": "items[2]: \"a\tb\",\"c\td\"",
+ "expected": {
+ "items": ["a\tb", "c\td"]
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "accepts non-multiple indentation when strict=false",
+ "input": "a:\n b: 1",
+ "expected": {
+ "a": {
+ "b": 1
+ }
+ },
+ "options": {
+ "indent": 2,
+ "strict": false
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "accepts deeply nested non-multiples when strict=false",
+ "input": "a:\n b:\n c: 1",
+ "expected": {
+ "a": {
+ "b": {
+ "c": 1
+ }
+ }
+ },
+ "options": {
+ "indent": 2,
+ "strict": false
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "empty lines do not trigger validation errors",
+ "input": "a: 1\n\nb: 2",
+ "expected": {
+ "a": 1,
+ "b": 2
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "root-level content (0 indentation) is always valid",
+ "input": "a: 1\nb: 2\nc: 3",
+ "expected": {
+ "a": 1,
+ "b": 2,
+ "c": 3
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "lines with only spaces are not validated if empty",
+ "input": "a: 1\n \nb: 2",
+ "expected": {
+ "a": 1,
+ "b": 2
+ },
+ "options": {
+ "strict": true
+ },
+ "specSection": "12"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/numbers.json b/src/test/resources/conformance/decode/numbers.json
new file mode 100644
index 0000000..9e5854f
--- /dev/null
+++ b/src/test/resources/conformance/decode/numbers.json
@@ -0,0 +1,142 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Number decoding edge cases - trailing zeros, exponent forms, negative zero",
+ "tests": [
+ {
+ "name": "parses number with trailing zeros in fractional part",
+ "input": "value: 1.5000",
+ "expected": {
+ "value": 1.5
+ },
+ "specSection": "4",
+ "note": "Decoders accept trailing zeros; numeric value is 1.5"
+ },
+ {
+ "name": "parses negative number with positive exponent",
+ "input": "value: -1E+03",
+ "expected": {
+ "value": -1000
+ },
+ "specSection": "4",
+ "note": "Exponent forms are accepted by decoders"
+ },
+ {
+ "name": "parses lowercase exponent",
+ "input": "value: 2.5e2",
+ "expected": {
+ "value": 250
+ },
+ "specSection": "4"
+ },
+ {
+ "name": "parses uppercase exponent with negative sign",
+ "input": "value: 3E-02",
+ "expected": {
+ "value": 0.03
+ },
+ "specSection": "4"
+ },
+ {
+ "name": "parses negative zero as zero",
+ "input": "value: -0",
+ "expected": {
+ "value": 0
+ },
+ "specSection": "4",
+ "note": "Negative zero decodes to 0; most host environments do not distinguish -0 from 0"
+ },
+ {
+ "name": "parses negative zero with fractional part",
+ "input": "value: -0.0",
+ "expected": {
+ "value": 0
+ },
+ "specSection": "4"
+ },
+ {
+ "name": "parses array with mixed numeric forms",
+ "input": "nums[5]: 42,-1E+03,1.5000,-0,2.5e2",
+ "expected": {
+ "nums": [42, -1000, 1.5, 0, 250]
+ },
+ "specSection": "4",
+ "note": "Decoders normalize all numeric forms to host numeric values"
+ },
+ {
+ "name": "treats leading zero as string not number",
+ "input": "value: 05",
+ "expected": {
+ "value": "05"
+ },
+ "specSection": "4",
+ "note": "Forbidden leading zeros cause tokens to be treated as strings"
+ },
+ {
+ "name": "parses very small exponent",
+ "input": "value: 1e-10",
+ "expected": {
+ "value": 0.0000000001
+ },
+ "specSection": "4"
+ },
+ {
+ "name": "parses integer with positive exponent",
+ "input": "value: 5E+00",
+ "expected": {
+ "value": 5
+ },
+ "specSection": "4",
+ "note": "Exponent +00 results in the integer 5"
+ },
+ {
+ "name": "parses exponent notation",
+ "input": "1e6",
+ "expected": 1000000,
+ "specSection": "4"
+ },
+ {
+ "name": "parses exponent notation with uppercase E",
+ "input": "1E+6",
+ "expected": 1000000,
+ "specSection": "4"
+ },
+ {
+ "name": "parses negative exponent notation",
+ "input": "-1e-3",
+ "expected": -0.001,
+ "specSection": "4"
+ },
+ {
+ "name": "treats unquoted leading-zero number as string",
+ "input": "05",
+ "expected": "05",
+ "specSection": "4",
+ "note": "Leading zeros make it a string"
+ },
+ {
+ "name": "treats unquoted multi-leading-zero as string",
+ "input": "007",
+ "expected": "007",
+ "specSection": "4"
+ },
+ {
+ "name": "treats unquoted octal-like as string",
+ "input": "0123",
+ "expected": "0123",
+ "specSection": "4"
+ },
+ {
+ "name": "treats leading-zero in object value as string",
+ "input": "a: 05",
+ "expected": { "a": "05" },
+ "specSection": "4"
+ },
+ {
+ "name": "treats leading-zeros in array as strings",
+ "input": "nums[3]: 05,007,0123",
+ "expected": { "nums": ["05", "007", "0123"] },
+ "specSection": "4"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/objects.json b/src/test/resources/conformance/decode/objects.json
new file mode 100644
index 0000000..c032b87
--- /dev/null
+++ b/src/test/resources/conformance/decode/objects.json
@@ -0,0 +1,238 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Object decoding - simple objects, nested objects, key parsing, quoted values",
+ "tests": [
+ {
+ "name": "parses objects with primitive values",
+ "input": "id: 123\nname: Ada\nactive: true",
+ "expected": {
+ "id": 123,
+ "name": "Ada",
+ "active": true
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses null values in objects",
+ "input": "id: 123\nvalue: null",
+ "expected": {
+ "id": 123,
+ "value": null
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses empty nested object header",
+ "input": "user:",
+ "expected": {
+ "user": {}
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted object value with colon",
+ "input": "note: \"a:b\"",
+ "expected": {
+ "note": "a:b"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted object value with comma",
+ "input": "note: \"a,b\"",
+ "expected": {
+ "note": "a,b"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted object value with newline escape",
+ "input": "text: \"line1\\nline2\"",
+ "expected": {
+ "text": "line1\nline2"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted object value with escaped quotes",
+ "input": "text: \"say \\\"hello\\\"\"",
+ "expected": {
+ "text": "say \"hello\""
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted object value with leading/trailing spaces",
+ "input": "text: \" padded \"",
+ "expected": {
+ "text": " padded "
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted object value with only spaces",
+ "input": "text: \" \"",
+ "expected": {
+ "text": " "
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted string value that looks like true",
+ "input": "v: \"true\"",
+ "expected": {
+ "v": "true"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted string value that looks like integer",
+ "input": "v: \"42\"",
+ "expected": {
+ "v": "42"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted string value that looks like negative decimal",
+ "input": "v: \"-7.5\"",
+ "expected": {
+ "v": "-7.5"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted key with colon",
+ "input": "\"order:id\": 7",
+ "expected": {
+ "order:id": 7
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted key with brackets",
+ "input": "\"[index]\": 5",
+ "expected": {
+ "[index]": 5
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted key with braces",
+ "input": "\"{key}\": 5",
+ "expected": {
+ "{key}": 5
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted key with comma",
+ "input": "\"a,b\": 1",
+ "expected": {
+ "a,b": 1
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted key with spaces",
+ "input": "\"full name\": Ada",
+ "expected": {
+ "full name": "Ada"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted key with leading hyphen",
+ "input": "\"-lead\": 1",
+ "expected": {
+ "-lead": 1
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted key with leading and trailing spaces",
+ "input": "\" a \": 1",
+ "expected": {
+ " a ": 1
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted numeric key",
+ "input": "\"123\": x",
+ "expected": {
+ "123": "x"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses quoted empty string key",
+ "input": "\"\": 1",
+ "expected": {
+ "": 1
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses dotted keys as identifiers",
+ "input": "user.name: Ada",
+ "expected": {
+ "user.name": "Ada"
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses underscore-prefixed keys",
+ "input": "_private: 1",
+ "expected": {
+ "_private": 1
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses underscore-containing keys",
+ "input": "user_name: 1",
+ "expected": {
+ "user_name": 1
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "unescapes newline in key",
+ "input": "\"line\\nbreak\": 1",
+ "expected": {
+ "line\nbreak": 1
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "unescapes tab in key",
+ "input": "\"tab\\there\": 2",
+ "expected": {
+ "tab\there": 2
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "unescapes quotes in key",
+ "input": "\"he said \\\"hi\\\"\": 1",
+ "expected": {
+ "he said \"hi\"": 1
+ },
+ "specSection": "8"
+ },
+ {
+ "name": "parses deeply nested objects with indentation",
+ "input": "a:\n b:\n c: deep",
+ "expected": {
+ "a": {
+ "b": {
+ "c": "deep"
+ }
+ }
+ },
+ "specSection": "8"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/path-expansion.json b/src/test/resources/conformance/decode/path-expansion.json
new file mode 100644
index 0000000..5eb9cb4
--- /dev/null
+++ b/src/test/resources/conformance/decode/path-expansion.json
@@ -0,0 +1,173 @@
+{
+ "version": "1.5",
+ "category": "decode",
+ "description": "Path expansion with safe mode, deep merge, conflict resolution tied to strict mode",
+ "tests": [
+ {
+ "name": "expands dotted key to nested object in safe mode",
+ "input": "a.b.c: 1",
+ "expected": {
+ "a": {
+ "b": {
+ "c": 1
+ }
+ }
+ },
+ "options": {
+ "expandPaths": "safe"
+ },
+ "specSection": "13.4"
+ },
+ {
+ "name": "expands dotted key with inline array",
+ "input": "data.meta.items[2]: a,b",
+ "expected": {
+ "data": {
+ "meta": {
+ "items": ["a", "b"]
+ }
+ }
+ },
+ "options": {
+ "expandPaths": "safe"
+ },
+ "specSection": "13.4"
+ },
+ {
+ "name": "expands dotted key with tabular array",
+ "input": "a.b.items[2]{id,name}:\n 1,A\n 2,B",
+ "expected": {
+ "a": {
+ "b": {
+ "items": [
+ { "id": 1, "name": "A" },
+ { "id": 2, "name": "B" }
+ ]
+ }
+ }
+ },
+ "options": {
+ "expandPaths": "safe"
+ },
+ "specSection": "13.4"
+ },
+ {
+ "name": "preserves literal dotted keys when expansion is off",
+ "input": "user.name: Ada",
+ "expected": {
+ "user.name": "Ada"
+ },
+ "options": {
+ "expandPaths": "off"
+ },
+ "specSection": "13.4"
+ },
+ {
+ "name": "expands and deep-merges preserving document-order insertion",
+ "input": "a.b.c: 1\na.b.d: 2\na.e: 3",
+ "expected": {
+ "a": {
+ "b": {
+ "c": 1,
+ "d": 2
+ },
+ "e": 3
+ }
+ },
+ "options": {
+ "expandPaths": "safe"
+ },
+ "specSection": "13.4"
+ },
+ {
+ "name": "throws on expansion conflict (object vs primitive) when strict=true",
+ "input": "a.b: 1\na: 2",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "expandPaths": "safe",
+ "strict": true
+ },
+ "specSection": "14.5"
+ },
+ {
+ "name": "throws on expansion conflict (object vs array) when strict=true",
+ "input": "a.b: 1\na[2]: 2,3",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "expandPaths": "safe",
+ "strict": true
+ },
+ "specSection": "14.5"
+ },
+ {
+ "name": "applies LWW when strict=false (primitive overwrites expanded object)",
+ "input": "a.b: 1\na: 2",
+ "expected": {
+ "a": 2
+ },
+ "options": {
+ "expandPaths": "safe",
+ "strict": false
+ },
+ "specSection": "13.4",
+ "note": "Document order determines winner: later key overwrites earlier"
+ },
+ {
+ "name": "applies LWW when strict=false (expanded object overwrites primitive)",
+ "input": "a: 1\na.b: 2",
+ "expected": {
+ "a": {
+ "b": 2
+ }
+ },
+ "options": {
+ "expandPaths": "safe",
+ "strict": false
+ },
+ "specSection": "13.4",
+ "note": "Document order determines winner: later key overwrites earlier"
+ },
+ {
+ "name": "preserves quoted dotted key as literal when expandPaths=safe",
+ "input": "a.b: 1\n\"c.d\": 2",
+ "expected": {
+ "a": {
+ "b": 1
+ },
+ "c.d": 2
+ },
+ "options": {
+ "expandPaths": "safe"
+ },
+ "specSection": "13.4"
+ },
+ {
+ "name": "preserves non-IdentifierSegment keys as literals",
+ "input": "full-name.x: 1",
+ "expected": {
+ "full-name.x": 1
+ },
+ "options": {
+ "expandPaths": "safe"
+ },
+ "specSection": "13.4"
+ },
+ {
+ "name": "expands keys creating empty nested objects",
+ "input": "a.b.c:",
+ "expected": {
+ "a": {
+ "b": {
+ "c": {}
+ }
+ }
+ },
+ "options": {
+ "expandPaths": "safe"
+ },
+ "specSection": "13.4"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/primitives.json b/src/test/resources/conformance/decode/primitives.json
new file mode 100644
index 0000000..0566814
--- /dev/null
+++ b/src/test/resources/conformance/decode/primitives.json
@@ -0,0 +1,158 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Primitive value decoding - strings, numbers, booleans, null, unescaping",
+ "tests": [
+ {
+ "name": "parses safe unquoted string",
+ "input": "hello",
+ "expected": "hello",
+ "specSection": "7.4"
+ },
+ {
+ "name": "parses unquoted string with underscore and numbers",
+ "input": "Ada_99",
+ "expected": "Ada_99",
+ "specSection": "7.4"
+ },
+ {
+ "name": "parses empty quoted string",
+ "input": "\"\"",
+ "expected": "",
+ "specSection": "7.4"
+ },
+ {
+ "name": "parses quoted string with newline escape",
+ "input": "\"line1\\nline2\"",
+ "expected": "line1\nline2",
+ "specSection": "7.1"
+ },
+ {
+ "name": "parses quoted string with tab escape",
+ "input": "\"tab\\there\"",
+ "expected": "tab\there",
+ "specSection": "7.1"
+ },
+ {
+ "name": "parses quoted string with carriage return escape",
+ "input": "\"return\\rcarriage\"",
+ "expected": "return\rcarriage",
+ "specSection": "7.1"
+ },
+ {
+ "name": "parses quoted string with backslash escape",
+ "input": "\"C:\\\\Users\\\\path\"",
+ "expected": "C:\\Users\\path",
+ "specSection": "7.1"
+ },
+ {
+ "name": "parses quoted string with escaped quotes",
+ "input": "\"say \\\"hello\\\"\"",
+ "expected": "say \"hello\"",
+ "specSection": "7.1"
+ },
+ {
+ "name": "parses Unicode string",
+ "input": "café",
+ "expected": "café",
+ "specSection": "7.4"
+ },
+ {
+ "name": "parses Chinese characters",
+ "input": "你好",
+ "expected": "你好",
+ "specSection": "7.4"
+ },
+ {
+ "name": "parses emoji",
+ "input": "🚀",
+ "expected": "🚀",
+ "specSection": "7.4"
+ },
+ {
+ "name": "parses string with emoji and spaces",
+ "input": "hello 👋 world",
+ "expected": "hello 👋 world",
+ "specSection": "7.4"
+ },
+ {
+ "name": "parses positive integer",
+ "input": "42",
+ "expected": 42,
+ "specSection": "4"
+ },
+ {
+ "name": "parses decimal number",
+ "input": "3.14",
+ "expected": 3.14,
+ "specSection": "4"
+ },
+ {
+ "name": "parses negative integer",
+ "input": "-7",
+ "expected": -7,
+ "specSection": "4"
+ },
+ {
+ "name": "parses true",
+ "input": "true",
+ "expected": true,
+ "specSection": "4"
+ },
+ {
+ "name": "parses false",
+ "input": "false",
+ "expected": false,
+ "specSection": "4"
+ },
+ {
+ "name": "parses null",
+ "input": "null",
+ "expected": null,
+ "specSection": "4"
+ },
+ {
+ "name": "respects ambiguity quoting for true",
+ "input": "\"true\"",
+ "expected": "true",
+ "specSection": "7.4",
+ "note": "Quoted primitive remains string"
+ },
+ {
+ "name": "respects ambiguity quoting for false",
+ "input": "\"false\"",
+ "expected": "false",
+ "specSection": "7.4"
+ },
+ {
+ "name": "respects ambiguity quoting for null",
+ "input": "\"null\"",
+ "expected": "null",
+ "specSection": "7.4"
+ },
+ {
+ "name": "respects ambiguity quoting for integer",
+ "input": "\"42\"",
+ "expected": "42",
+ "specSection": "7.4"
+ },
+ {
+ "name": "respects ambiguity quoting for negative decimal",
+ "input": "\"-3.14\"",
+ "expected": "-3.14",
+ "specSection": "7.4"
+ },
+ {
+ "name": "respects ambiguity quoting for scientific notation",
+ "input": "\"1e-6\"",
+ "expected": "1e-6",
+ "specSection": "7.4"
+ },
+ {
+ "name": "respects ambiguity quoting for leading-zero",
+ "input": "\"05\"",
+ "expected": "05",
+ "specSection": "7.4"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/root-form.json b/src/test/resources/conformance/decode/root-form.json
new file mode 100644
index 0000000..b496d63
--- /dev/null
+++ b/src/test/resources/conformance/decode/root-form.json
@@ -0,0 +1,17 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Root form detection - empty document, single primitive, multiple primitives",
+ "tests": [
+ {
+ "name": "empty document decodes to empty object",
+ "input": "",
+ "expected": {},
+ "options": {
+ "strict": true
+ },
+ "specSection": "5",
+ "note": "Empty input (no non-empty lines) decodes to empty object"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/validation-errors.json b/src/test/resources/conformance/decode/validation-errors.json
new file mode 100644
index 0000000..16ca090
--- /dev/null
+++ b/src/test/resources/conformance/decode/validation-errors.json
@@ -0,0 +1,83 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Validation errors - length mismatches, invalid escapes, syntax errors, delimiter mismatches",
+ "tests": [
+ {
+ "name": "throws on array length mismatch (inline primitives - too many)",
+ "input": "tags[2]: a,b,c",
+ "expected": null,
+ "shouldError": true,
+ "specSection": "14.1"
+ },
+ {
+ "name": "throws on array length mismatch (list format - too many)",
+ "input": "items[1]:\n - 1\n - 2",
+ "expected": null,
+ "shouldError": true,
+ "specSection": "14.1"
+ },
+ {
+ "name": "throws when tabular row value count does not match header field count",
+ "input": "items[2]{id,name}:\n 1,Ada\n 2",
+ "expected": null,
+ "shouldError": true,
+ "specSection": "14.1"
+ },
+ {
+ "name": "throws when tabular row count does not match header length",
+ "input": "[1]{id}:\n 1\n 2",
+ "expected": null,
+ "shouldError": true,
+ "specSection": "14.1"
+ },
+ {
+ "name": "throws on invalid escape sequence",
+ "input": "\"a\\x\"",
+ "expected": null,
+ "shouldError": true,
+ "specSection": "14.2"
+ },
+ {
+ "name": "throws on unterminated string",
+ "input": "\"unterminated",
+ "expected": null,
+ "shouldError": true,
+ "specSection": "14.2"
+ },
+ {
+ "name": "throws on missing colon in key-value context",
+ "input": "a:\n user",
+ "expected": null,
+ "shouldError": true,
+ "specSection": "14.2"
+ },
+ {
+ "name": "throws on two primitives at root depth in strict mode",
+ "input": "hello\nworld",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "5"
+ },
+ {
+ "name": "throws on delimiter mismatch (header declares tab, row uses comma)",
+ "input": "items[2\t]{a\tb}:\n 1,2\n 3,4",
+ "expected": null,
+ "shouldError": true,
+ "specSection": "14.2"
+ },
+ {
+ "name": "throws on mismatched delimiter between bracket and brace fields",
+ "input": "items[2\t]{a,b}:\n 1\t2\n 3\t4",
+ "expected": null,
+ "shouldError": true,
+ "options": {
+ "strict": true
+ },
+ "specSection": "6"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/decode/whitespace.json b/src/test/resources/conformance/decode/whitespace.json
new file mode 100644
index 0000000..cc3eb64
--- /dev/null
+++ b/src/test/resources/conformance/decode/whitespace.json
@@ -0,0 +1,61 @@
+{
+ "version": "1.4",
+ "category": "decode",
+ "description": "Whitespace tolerance in decoding - surrounding spaces around delimiters and values",
+ "tests": [
+ {
+ "name": "tolerates spaces around commas in inline arrays",
+ "input": "tags[3]: a , b , c",
+ "expected": {
+ "tags": ["a", "b", "c"]
+ },
+ "specSection": "12",
+ "note": "Surrounding whitespace SHOULD be tolerated; tokens are trimmed"
+ },
+ {
+ "name": "tolerates spaces around pipes in inline arrays",
+ "input": "tags[3|]: a | b | c",
+ "expected": {
+ "tags": ["a", "b", "c"]
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "tolerates spaces around tabs in inline arrays",
+ "input": "tags[3\t]: a \t b \t c",
+ "expected": {
+ "tags": ["a", "b", "c"]
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "tolerates leading and trailing spaces in tabular row values",
+ "input": "items[2]{id,name}:\n 1 , Alice \n 2 , Bob ",
+ "expected": {
+ "items": [
+ { "id": 1, "name": "Alice" },
+ { "id": 2, "name": "Bob" }
+ ]
+ },
+ "specSection": "12",
+ "note": "Values in tabular rows are trimmed"
+ },
+ {
+ "name": "tolerates spaces around delimiters with quoted values",
+ "input": "items[3]: \"a\" , \"b\" , \"c\"",
+ "expected": {
+ "items": ["a", "b", "c"]
+ },
+ "specSection": "12"
+ },
+ {
+ "name": "empty tokens decode to empty string",
+ "input": "items[3]: a,,c",
+ "expected": {
+ "items": ["a", "", "c"]
+ },
+ "specSection": "12",
+ "note": "Empty token (nothing between delimiters) decodes to empty string"
+ }
+ ]
+}
diff --git a/src/test/resources/conformance/encode/arrays-nested.json b/src/test/resources/conformance/encode/arrays-nested.json
new file mode 100644
index 0000000..b1d7b6d
--- /dev/null
+++ b/src/test/resources/conformance/encode/arrays-nested.json
@@ -0,0 +1,99 @@
+{
+ "version": "1.4",
+ "category": "encode",
+ "description": "Nested and mixed array encoding - arrays of arrays, mixed type arrays, root arrays",
+ "tests": [
+ {
+ "name": "encodes nested arrays of primitives",
+ "input": {
+ "pairs": [["a", "b"], ["c", "d"]]
+ },
+ "expected": "pairs[2]:\n - [2]: a,b\n - [2]: c,d",
+ "specSection": "9.2"
+ },
+ {
+ "name": "quotes strings containing delimiters in nested arrays",
+ "input": {
+ "pairs": [["a", "b"], ["c,d", "e:f", "true"]]
+ },
+ "expected": "pairs[2]:\n - [2]: a,b\n - [3]: \"c,d\",\"e:f\",\"true\"",
+ "specSection": "9.2"
+ },
+ {
+ "name": "encodes empty inner arrays",
+ "input": {
+ "pairs": [[], []]
+ },
+ "expected": "pairs[2]:\n - [0]:\n - [0]:",
+ "specSection": "9.2"
+ },
+ {
+ "name": "encodes mixed-length inner arrays",
+ "input": {
+ "pairs": [[1], [2, 3]]
+ },
+ "expected": "pairs[2]:\n - [1]: 1\n - [2]: 2,3",
+ "specSection": "9.2"
+ },
+ {
+ "name": "encodes root-level primitive array",
+ "input": ["x", "y", "true", true, 10],
+ "expected": "[5]: x,y,\"true\",true,10",
+ "specSection": "9.1"
+ },
+ {
+ "name": "encodes root-level array of uniform objects in tabular format",
+ "input": [{ "id": 1 }, { "id": 2 }],
+ "expected": "[2]{id}:\n 1\n 2",
+ "specSection": "9.3"
+ },
+ {
+ "name": "encodes root-level array of non-uniform objects in list format",
+ "input": [{ "id": 1 }, { "id": 2, "name": "Ada" }],
+ "expected": "[2]:\n - id: 1\n - id: 2\n name: Ada",
+ "specSection": "9.4"
+ },
+ {
+ "name": "encodes empty root-level array",
+ "input": [],
+ "expected": "[0]:",
+ "specSection": "9.1"
+ },
+ {
+ "name": "encodes root-level arrays of arrays",
+ "input": [[1, 2], []],
+ "expected": "[2]:\n - [2]: 1,2\n - [0]:",
+ "specSection": "9.2"
+ },
+ {
+ "name": "encodes complex nested structure",
+ "input": {
+ "user": {
+ "id": 123,
+ "name": "Ada",
+ "tags": ["reading", "gaming"],
+ "active": true,
+ "prefs": []
+ }
+ },
+ "expected": "user:\n id: 123\n name: Ada\n tags[2]: reading,gaming\n active: true\n prefs[0]:",
+ "specSection": "8"
+ },
+ {
+ "name": "uses list format for arrays mixing primitives and objects",
+ "input": {
+ "items": [1, { "a": 1 }, "text"]
+ },
+ "expected": "items[3]:\n - 1\n - a: 1\n - text",
+ "specSection": "9.4"
+ },
+ {
+ "name": "uses list format for arrays mixing objects and arrays",
+ "input": {
+ "items": [{ "a": 1 }, [1, 2]]
+ },
+ "expected": "items[2]:\n - a: 1\n - [2]: 1,2",
+ "specSection": "9.4"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/conformance/encode/arrays-objects.json b/src/test/resources/conformance/encode/arrays-objects.json
new file mode 100644
index 0000000..2b0e77d
--- /dev/null
+++ b/src/test/resources/conformance/encode/arrays-objects.json
@@ -0,0 +1,138 @@
+{
+ "version": "1.4",
+ "category": "encode",
+ "description": "Arrays of objects encoding - list format for non-uniform objects and complex structures",
+ "tests": [
+ {
+ "name": "uses list format for objects with different fields",
+ "input": {
+ "items": [
+ { "id": 1, "name": "First" },
+ { "id": 2, "name": "Second", "extra": true }
+ ]
+ },
+ "expected": "items[2]:\n - id: 1\n name: First\n - id: 2\n name: Second\n extra: true",
+ "specSection": "9.4"
+ },
+ {
+ "name": "uses list format for objects with nested values",
+ "input": {
+ "items": [
+ { "id": 1, "nested": { "x": 1 } }
+ ]
+ },
+ "expected": "items[1]:\n - id: 1\n nested:\n x: 1",
+ "specSection": "9.4"
+ },
+ {
+ "name": "preserves field order in list items - array first",
+ "input": {
+ "items": [{ "nums": [1, 2, 3], "name": "test" }]
+ },
+ "expected": "items[1]:\n - nums[3]: 1,2,3\n name: test",
+ "specSection": "10"
+ },
+ {
+ "name": "preserves field order in list items - primitive first",
+ "input": {
+ "items": [{ "name": "test", "nums": [1, 2, 3] }]
+ },
+ "expected": "items[1]:\n - name: test\n nums[3]: 1,2,3",
+ "specSection": "10"
+ },
+ {
+ "name": "uses list format for objects containing arrays of arrays",
+ "input": {
+ "items": [
+ { "matrix": [[1, 2], [3, 4]], "name": "grid" }
+ ]
+ },
+ "expected": "items[1]:\n - matrix[2]:\n - [2]: 1,2\n - [2]: 3,4\n name: grid",
+ "specSection": "10"
+ },
+ {
+ "name": "uses tabular format for nested uniform object arrays",
+ "input": {
+ "items": [
+ { "users": [{ "id": 1, "name": "Ada" }, { "id": 2, "name": "Bob" }], "status": "active" }
+ ]
+ },
+ "expected": "items[1]:\n - users[2]{id,name}:\n 1,Ada\n 2,Bob\n status: active",
+ "specSection": "10"
+ },
+ {
+ "name": "uses list format for nested object arrays with mismatched keys",
+ "input": {
+ "items": [
+ { "users": [{ "id": 1, "name": "Ada" }, { "id": 2 }], "status": "active" }
+ ]
+ },
+ "expected": "items[1]:\n - users[2]:\n - id: 1\n name: Ada\n - id: 2\n status: active",
+ "specSection": "10"
+ },
+ {
+ "name": "uses list format for objects with multiple array fields",
+ "input": {
+ "items": [{ "nums": [1, 2], "tags": ["a", "b"], "name": "test" }]
+ },
+ "expected": "items[1]:\n - nums[2]: 1,2\n tags[2]: a,b\n name: test",
+ "specSection": "10"
+ },
+ {
+ "name": "uses list format for objects with only array fields",
+ "input": {
+ "items": [{ "nums": [1, 2, 3], "tags": ["a", "b"] }]
+ },
+ "expected": "items[1]:\n - nums[3]: 1,2,3\n tags[2]: a,b",
+ "specSection": "10"
+ },
+ {
+ "name": "encodes objects with empty arrays in list format",
+ "input": {
+ "items": [
+ { "name": "test", "data": [] }
+ ]
+ },
+ "expected": "items[1]:\n - name: test\n data[0]:",
+ "specSection": "10"
+ },
+ {
+ "name": "places first field of nested tabular arrays on hyphen line",
+ "input": {
+ "items": [{ "users": [{ "id": 1 }, { "id": 2 }], "note": "x" }]
+ },
+ "expected": "items[1]:\n - users[2]{id}:\n 1\n 2\n note: x",
+ "specSection": "10"
+ },
+ {
+ "name": "places empty arrays on hyphen line when first",
+ "input": {
+ "items": [{ "data": [], "name": "x" }]
+ },
+ "expected": "items[1]:\n - data[0]:\n name: x",
+ "specSection": "10"
+ },
+ {
+ "name": "uses field order from first object for tabular headers",
+ "input": {
+ "items": [
+ { "a": 1, "b": 2, "c": 3 },
+ { "c": 30, "b": 20, "a": 10 }
+ ]
+ },
+ "expected": "items[2]{a,b,c}:\n 1,2,3\n 10,20,30",
+ "specSection": "9.3"
+ },
+ {
+ "name": "uses list format when one object has nested column",
+ "input": {
+ "items": [
+ { "id": 1, "data": "string" },
+ { "id": 2, "data": { "nested": true } }
+ ]
+ },
+ "expected": "items[2]:\n - id: 1\n data: string\n - id: 2\n data:\n nested: true",
+ "specSection": "9.4"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/conformance/encode/arrays-primitive.json b/src/test/resources/conformance/encode/arrays-primitive.json
new file mode 100644
index 0000000..c0297d4
--- /dev/null
+++ b/src/test/resources/conformance/encode/arrays-primitive.json
@@ -0,0 +1,87 @@
+{
+ "version": "1.4",
+ "category": "encode",
+ "description": "Primitive array encoding - inline arrays of strings, numbers, booleans",
+ "tests": [
+ {
+ "name": "encodes string arrays inline",
+ "input": {
+ "tags": ["reading", "gaming"]
+ },
+ "expected": "tags[2]: reading,gaming",
+ "specSection": "9.1"
+ },
+ {
+ "name": "encodes number arrays inline",
+ "input": {
+ "nums": [1, 2, 3]
+ },
+ "expected": "nums[3]: 1,2,3",
+ "specSection": "9.1"
+ },
+ {
+ "name": "encodes mixed primitive arrays inline",
+ "input": {
+ "data": ["x", "y", true, 10]
+ },
+ "expected": "data[4]: x,y,true,10",
+ "specSection": "9.1"
+ },
+ {
+ "name": "encodes empty arrays",
+ "input": {
+ "items": []
+ },
+ "expected": "items[0]:",
+ "specSection": "9.1"
+ },
+ {
+ "name": "encodes empty string in single-item array",
+ "input": {
+ "items": [""]
+ },
+ "expected": "items[1]: \"\"",
+ "specSection": "9.1"
+ },
+ {
+ "name": "encodes empty string in multi-item array",
+ "input": {
+ "items": ["a", "", "b"]
+ },
+ "expected": "items[3]: a,\"\",b",
+ "specSection": "9.1"
+ },
+ {
+ "name": "encodes whitespace-only strings in arrays",
+ "input": {
+ "items": [" ", " "]
+ },
+ "expected": "items[2]: \" \",\" \"",
+ "specSection": "9.1"
+ },
+ {
+ "name": "quotes array strings with comma",
+ "input": {
+ "items": ["a", "b,c", "d:e"]
+ },
+ "expected": "items[3]: a,\"b,c\",\"d:e\"",
+ "specSection": "9.1"
+ },
+ {
+ "name": "quotes strings that look like booleans in arrays",
+ "input": {
+ "items": ["x", "true", "42", "-3.14"]
+ },
+ "expected": "items[4]: x,\"true\",\"42\",\"-3.14\"",
+ "specSection": "9.1"
+ },
+ {
+ "name": "quotes strings with structural meanings in arrays",
+ "input": {
+ "items": ["[5]", "- item", "{key}"]
+ },
+ "expected": "items[3]: \"[5]\",\"- item\",\"{key}\"",
+ "specSection": "9.1"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/conformance/encode/delimeters.json b/src/test/resources/conformance/encode/delimeters.json
new file mode 100644
index 0000000..f893ec3
--- /dev/null
+++ b/src/test/resources/conformance/encode/delimeters.json
@@ -0,0 +1,253 @@
+{
+ "version": "1.4",
+ "category": "encode",
+ "description": "Delimiter options - tab and pipe delimiters, delimiter-aware quoting",
+ "tests": [
+ {
+ "name": "encodes primitive arrays with tab delimiter",
+ "input": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "expected": "tags[3\t]: reading\tgaming\tcoding",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes primitive arrays with pipe delimiter",
+ "input": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "expected": "tags[3|]: reading|gaming|coding",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes primitive arrays with comma delimiter",
+ "input": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "expected": "tags[3]: reading,gaming,coding",
+ "options": {
+ "delimiter": ","
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes tabular arrays with tab delimiter",
+ "input": {
+ "items": [
+ { "sku": "A1", "qty": 2, "price": 9.99 },
+ { "sku": "B2", "qty": 1, "price": 14.5 }
+ ]
+ },
+ "expected": "items[2\t]{sku\tqty\tprice}:\n A1\t2\t9.99\n B2\t1\t14.5",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes tabular arrays with pipe delimiter",
+ "input": {
+ "items": [
+ { "sku": "A1", "qty": 2, "price": 9.99 },
+ { "sku": "B2", "qty": 1, "price": 14.5 }
+ ]
+ },
+ "expected": "items[2|]{sku|qty|price}:\n A1|2|9.99\n B2|1|14.5",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes nested arrays with tab delimiter",
+ "input": {
+ "pairs": [["a", "b"], ["c", "d"]]
+ },
+ "expected": "pairs[2\t]:\n - [2\t]: a\tb\n - [2\t]: c\td",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes nested arrays with pipe delimiter",
+ "input": {
+ "pairs": [["a", "b"], ["c", "d"]]
+ },
+ "expected": "pairs[2|]:\n - [2|]: a|b\n - [2|]: c|d",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes root arrays with tab delimiter",
+ "input": ["x", "y", "z"],
+ "expected": "[3\t]: x\ty\tz",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes root arrays with pipe delimiter",
+ "input": ["x", "y", "z"],
+ "expected": "[3|]: x|y|z",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes root arrays of objects with tab delimiter",
+ "input": [{ "id": 1 }, { "id": 2 }],
+ "expected": "[2\t]{id}:\n 1\n 2",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "encodes root arrays of objects with pipe delimiter",
+ "input": [{ "id": 1 }, { "id": 2 }],
+ "expected": "[2|]{id}:\n 1\n 2",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "quotes strings containing tab delimiter",
+ "input": {
+ "items": ["a", "b\tc", "d"]
+ },
+ "expected": "items[3\t]: a\t\"b\\tc\"\td",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "quotes strings containing pipe delimiter",
+ "input": {
+ "items": ["a", "b|c", "d"]
+ },
+ "expected": "items[3|]: a|\"b|c\"|d",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not quote commas with tab delimiter",
+ "input": {
+ "items": ["a,b", "c,d"]
+ },
+ "expected": "items[2\t]: a,b\tc,d",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not quote commas with pipe delimiter",
+ "input": {
+ "items": ["a,b", "c,d"]
+ },
+ "expected": "items[2|]: a,b|c,d",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "quotes tabular values containing comma delimiter",
+ "input": {
+ "items": [
+ { "id": 1, "note": "a,b" },
+ { "id": 2, "note": "c,d" }
+ ]
+ },
+ "expected": "items[2]{id,note}:\n 1,\"a,b\"\n 2,\"c,d\"",
+ "options": {
+ "delimiter": ","
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not quote commas in tabular values with tab delimiter",
+ "input": {
+ "items": [
+ { "id": 1, "note": "a,b" },
+ { "id": 2, "note": "c,d" }
+ ]
+ },
+ "expected": "items[2\t]{id\tnote}:\n 1\ta,b\n 2\tc,d",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not quote commas in object values with pipe delimiter",
+ "input": {
+ "note": "a,b"
+ },
+ "expected": "note: a,b",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "does not quote commas in object values with tab delimiter",
+ "input": {
+ "note": "a,b"
+ },
+ "expected": "note: a,b",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "quotes nested array values containing pipe delimiter",
+ "input": {
+ "pairs": [["a", "b|c"]]
+ },
+ "expected": "pairs[1|]:\n - [2|]: a|\"b|c\"",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "quotes nested array values containing tab delimiter",
+ "input": {
+ "pairs": [["a", "b\tc"]]
+ },
+ "expected": "pairs[1\t]:\n - [2\t]: a\t\"b\\tc\"",
+ "options": {
+ "delimiter": "\t"
+ },
+ "specSection": "11"
+ },
+ {
+ "name": "preserves ambiguity quoting regardless of delimiter",
+ "input": {
+ "items": ["true", "42", "-3.14"]
+ },
+ "expected": "items[3|]: \"true\"|\"42\"|\"-3.14\"",
+ "options": {
+ "delimiter": "|"
+ },
+ "specSection": "11"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/conformance/encode/objects.json b/src/test/resources/conformance/encode/objects.json
new file mode 100644
index 0000000..40ecf5b
--- /dev/null
+++ b/src/test/resources/conformance/encode/objects.json
@@ -0,0 +1,220 @@
+{
+ "version": "1.4",
+ "category": "encode",
+ "description": "Object encoding - simple objects, nested objects, key encoding",
+ "tests": [
+ {
+ "name": "preserves key order in objects",
+ "input": {
+ "id": 123,
+ "name": "Ada",
+ "active": true
+ },
+ "expected": "id: 123\nname: Ada\nactive: true",
+ "specSection": "8"
+ },
+ {
+ "name": "encodes null values in objects",
+ "input": {
+ "id": 123,
+ "value": null
+ },
+ "expected": "id: 123\nvalue: null",
+ "specSection": "8"
+ },
+ {
+ "name": "encodes empty objects as empty string",
+ "input": {},
+ "expected": "",
+ "specSection": "8"
+ },
+ {
+ "name": "quotes string value with colon",
+ "input": {
+ "note": "a:b"
+ },
+ "expected": "note: \"a:b\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string value with comma",
+ "input": {
+ "note": "a,b"
+ },
+ "expected": "note: \"a,b\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string value with newline",
+ "input": {
+ "text": "line1\nline2"
+ },
+ "expected": "text: \"line1\\nline2\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string value with embedded quotes",
+ "input": {
+ "text": "say \"hello\""
+ },
+ "expected": "text: \"say \\\"hello\\\"\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string value with leading space",
+ "input": {
+ "text": " padded "
+ },
+ "expected": "text: \" padded \"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string value with only spaces",
+ "input": {
+ "text": " "
+ },
+ "expected": "text: \" \"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string value that looks like true",
+ "input": {
+ "v": "true"
+ },
+ "expected": "v: \"true\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string value that looks like number",
+ "input": {
+ "v": "42"
+ },
+ "expected": "v: \"42\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string value that looks like negative decimal",
+ "input": {
+ "v": "-7.5"
+ },
+ "expected": "v: \"-7.5\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes key with colon",
+ "input": {
+ "order:id": 7
+ },
+ "expected": "\"order:id\": 7",
+ "specSection": "7.3"
+ },
+ {
+ "name": "quotes key with brackets",
+ "input": {
+ "[index]": 5
+ },
+ "expected": "\"[index]\": 5",
+ "specSection": "7.3"
+ },
+ {
+ "name": "quotes key with braces",
+ "input": {
+ "{key}": 5
+ },
+ "expected": "\"{key}\": 5",
+ "specSection": "7.3"
+ },
+ {
+ "name": "quotes key with comma",
+ "input": {
+ "a,b": 1
+ },
+ "expected": "\"a,b\": 1",
+ "specSection": "7.3"
+ },
+ {
+ "name": "quotes key with spaces",
+ "input": {
+ "full name": "Ada"
+ },
+ "expected": "\"full name\": Ada",
+ "specSection": "7.3"
+ },
+ {
+ "name": "quotes key with leading hyphen",
+ "input": {
+ "-lead": 1
+ },
+ "expected": "\"-lead\": 1",
+ "specSection": "7.3"
+ },
+ {
+ "name": "quotes key with leading and trailing spaces",
+ "input": {
+ " a ": 1
+ },
+ "expected": "\" a \": 1",
+ "specSection": "7.3"
+ },
+ {
+ "name": "quotes numeric key",
+ "input": {
+ "123": "x"
+ },
+ "expected": "\"123\": x",
+ "specSection": "7.3"
+ },
+ {
+ "name": "quotes empty string key",
+ "input": {
+ "": 1
+ },
+ "expected": "\"\": 1",
+ "specSection": "7.3"
+ },
+ {
+ "name": "escapes newline in key",
+ "input": {
+ "line\nbreak": 1
+ },
+ "expected": "\"line\\nbreak\": 1",
+ "specSection": "7.1"
+ },
+ {
+ "name": "escapes tab in key",
+ "input": {
+ "tab\there": 2
+ },
+ "expected": "\"tab\\there\": 2",
+ "specSection": "7.1"
+ },
+ {
+ "name": "escapes quotes in key",
+ "input": {
+ "he said \"hi\"": 1
+ },
+ "expected": "\"he said \\\"hi\\\"\": 1",
+ "specSection": "7.1"
+ },
+ {
+ "name": "encodes deeply nested objects",
+ "input": {
+ "a": {
+ "b": {
+ "c": "deep"
+ }
+ }
+ },
+ "expected": "a:\n b:\n c: deep",
+ "specSection": "8"
+ },
+ {
+ "name": "encodes empty nested object",
+ "input": {
+ "user": {}
+ },
+ "expected": "user:",
+ "specSection": "8"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/conformance/encode/options.json b/src/test/resources/conformance/encode/options.json
new file mode 100644
index 0000000..2f0903d
--- /dev/null
+++ b/src/test/resources/conformance/encode/options.json
@@ -0,0 +1,88 @@
+{
+ "version": "1.4",
+ "category": "encode",
+ "description": "Encoding options - lengthMarker option and combinations with delimiters",
+ "tests": [
+ {
+ "name": "adds length marker to primitive arrays",
+ "input": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "expected": "tags[#3]: reading,gaming,coding",
+ "options": {
+ "lengthMarker": "#"
+ },
+ "specSection": "3"
+ },
+ {
+ "name": "adds length marker to empty arrays",
+ "input": {
+ "items": []
+ },
+ "expected": "items[#0]:",
+ "options": {
+ "lengthMarker": "#"
+ },
+ "specSection": "3"
+ },
+ {
+ "name": "adds length marker to tabular arrays",
+ "input": {
+ "items": [
+ { "sku": "A1", "qty": 2, "price": 9.99 },
+ { "sku": "B2", "qty": 1, "price": 14.5 }
+ ]
+ },
+ "expected": "items[#2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5",
+ "options": {
+ "lengthMarker": "#"
+ },
+ "specSection": "3"
+ },
+ {
+ "name": "adds length marker to nested arrays",
+ "input": {
+ "pairs": [["a", "b"], ["c", "d"]]
+ },
+ "expected": "pairs[#2]:\n - [#2]: a,b\n - [#2]: c,d",
+ "options": {
+ "lengthMarker": "#"
+ },
+ "specSection": "3"
+ },
+ {
+ "name": "combines length marker with pipe delimiter",
+ "input": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "expected": "tags[#3|]: reading|gaming|coding",
+ "options": {
+ "lengthMarker": "#",
+ "delimiter": "|"
+ },
+ "specSection": "3"
+ },
+ {
+ "name": "combines length marker with tab delimiter",
+ "input": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "expected": "tags[#3\t]: reading\tgaming\tcoding",
+ "options": {
+ "lengthMarker": "#",
+ "delimiter": "\t"
+ },
+ "specSection": "3"
+ },
+ {
+ "name": "default lengthMarker is empty (no marker)",
+ "input": {
+ "tags": ["reading", "gaming", "coding"]
+ },
+ "expected": "tags[3]: reading,gaming,coding",
+ "options": {},
+ "specSection": "3",
+ "note": "Default behavior without lengthMarker option"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/conformance/encode/primitives.json b/src/test/resources/conformance/encode/primitives.json
new file mode 100644
index 0000000..a76b8a2
--- /dev/null
+++ b/src/test/resources/conformance/encode/primitives.json
@@ -0,0 +1,251 @@
+{
+ "version": "1.4",
+ "category": "encode",
+ "description": "Primitive value encoding - strings, numbers, booleans, null",
+ "tests": [
+ {
+ "name": "encodes safe strings without quotes",
+ "input": "hello",
+ "expected": "hello",
+ "specSection": "7.2"
+ },
+ {
+ "name": "encodes safe string with underscore and numbers",
+ "input": "Ada_99",
+ "expected": "Ada_99",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes empty string",
+ "input": "",
+ "expected": "\"\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string that looks like true",
+ "input": "true",
+ "expected": "\"true\"",
+ "specSection": "7.2",
+ "note": "String representation of boolean must be quoted"
+ },
+ {
+ "name": "quotes string that looks like false",
+ "input": "false",
+ "expected": "\"false\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string that looks like null",
+ "input": "null",
+ "expected": "\"null\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string that looks like integer",
+ "input": "42",
+ "expected": "\"42\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string that looks like negative decimal",
+ "input": "-3.14",
+ "expected": "\"-3.14\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string that looks like scientific notation",
+ "input": "1e-6",
+ "expected": "\"1e-6\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string with leading zero",
+ "input": "05",
+ "expected": "\"05\"",
+ "specSection": "7.2",
+ "note": "Leading zeros make it non-numeric"
+ },
+ {
+ "name": "escapes newline in string",
+ "input": "line1\nline2",
+ "expected": "\"line1\\nline2\"",
+ "specSection": "7.1"
+ },
+ {
+ "name": "escapes tab in string",
+ "input": "tab\there",
+ "expected": "\"tab\\there\"",
+ "specSection": "7.1"
+ },
+ {
+ "name": "escapes carriage return in string",
+ "input": "return\rcarriage",
+ "expected": "\"return\\rcarriage\"",
+ "specSection": "7.1"
+ },
+ {
+ "name": "escapes backslash in string",
+ "input": "C:\\Users\\path",
+ "expected": "\"C:\\\\Users\\\\path\"",
+ "specSection": "7.1"
+ },
+ {
+ "name": "quotes string with array-like syntax",
+ "input": "[3]: x,y",
+ "expected": "\"[3]: x,y\"",
+ "specSection": "7.2",
+ "note": "Looks like array header"
+ },
+ {
+ "name": "quotes string starting with hyphen-space",
+ "input": "- item",
+ "expected": "\"- item\"",
+ "specSection": "7.2",
+ "note": "Looks like list item marker"
+ },
+ {
+ "name": "quotes single hyphen as object value",
+ "input": { "marker": "-" },
+ "expected": "marker: \"-\"",
+ "specSection": "7.2",
+ "note": "Single hyphen must be quoted to avoid list item ambiguity"
+ },
+ {
+ "name": "quotes string starting with hyphen as object value",
+ "input": { "note": "- item" },
+ "expected": "note: \"- item\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes single hyphen in array",
+ "input": { "items": ["-"] },
+ "expected": "items[1]: \"-\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes leading-hyphen string in array",
+ "input": { "tags": ["a", "- item", "b"] },
+ "expected": "tags[3]: a,\"- item\",b",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string with bracket notation",
+ "input": "[test]",
+ "expected": "\"[test]\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "quotes string with brace notation",
+ "input": "{key}",
+ "expected": "\"{key}\"",
+ "specSection": "7.2"
+ },
+ {
+ "name": "encodes Unicode string without quotes",
+ "input": "café",
+ "expected": "café",
+ "specSection": "7.2"
+ },
+ {
+ "name": "encodes Chinese characters without quotes",
+ "input": "你好",
+ "expected": "你好",
+ "specSection": "7.2"
+ },
+ {
+ "name": "encodes emoji without quotes",
+ "input": "🚀",
+ "expected": "🚀",
+ "specSection": "7.2"
+ },
+ {
+ "name": "encodes string with emoji and spaces",
+ "input": "hello 👋 world",
+ "expected": "hello 👋 world",
+ "specSection": "7.2"
+ },
+ {
+ "name": "encodes positive integer",
+ "input": 42,
+ "expected": "42",
+ "specSection": "2"
+ },
+ {
+ "name": "encodes decimal number",
+ "input": 3.14,
+ "expected": "3.14",
+ "specSection": "2"
+ },
+ {
+ "name": "encodes negative integer",
+ "input": -7,
+ "expected": "-7",
+ "specSection": "2"
+ },
+ {
+ "name": "encodes zero",
+ "input": 0,
+ "expected": "0",
+ "specSection": "2"
+ },
+ {
+ "name": "encodes negative zero as zero",
+ "input": -0,
+ "expected": "0",
+ "specSection": "2",
+ "note": "Negative zero normalizes to zero"
+ },
+ {
+ "name": "encodes scientific notation as decimal",
+ "input": 1000000,
+ "expected": "1000000",
+ "specSection": "2",
+ "note": "1e6 input, but represented as decimal"
+ },
+ {
+ "name": "encodes small decimal from scientific notation",
+ "input": 0.000001,
+ "expected": "0.000001",
+ "specSection": "2",
+ "note": "1e-6 input"
+ },
+ {
+ "name": "encodes large number",
+ "input": 100000000000000000000,
+ "expected": "100000000000000000000",
+ "specSection": "2",
+ "note": "1e20"
+ },
+ {
+ "name": "encodes MAX_SAFE_INTEGER",
+ "input": 9007199254740991,
+ "expected": "9007199254740991",
+ "specSection": "2"
+ },
+ {
+ "name": "encodes repeating decimal with full precision",
+ "input": 0.3333333333333333,
+ "expected": "0.3333333333333333",
+ "specSection": "2",
+ "note": "Result of 1/3 in JavaScript"
+ },
+ {
+ "name": "encodes true",
+ "input": true,
+ "expected": "true",
+ "specSection": "2"
+ },
+ {
+ "name": "encodes false",
+ "input": false,
+ "expected": "false",
+ "specSection": "2"
+ },
+ {
+ "name": "encodes null",
+ "input": null,
+ "expected": "null",
+ "specSection": "2"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/conformance/encode/whitespace.json b/src/test/resources/conformance/encode/whitespace.json
new file mode 100644
index 0000000..140a57f
--- /dev/null
+++ b/src/test/resources/conformance/encode/whitespace.json
@@ -0,0 +1,44 @@
+{
+ "version": "1.4",
+ "category": "encode",
+ "description": "Whitespace and formatting invariants - no trailing spaces, no trailing newlines",
+ "tests": [
+ {
+ "name": "produces no trailing newline at end of output",
+ "input": {
+ "id": 123
+ },
+ "expected": "id: 123",
+ "specSection": "12",
+ "note": "Output should not end with newline character"
+ },
+ {
+ "name": "maintains proper indentation for nested structures",
+ "input": {
+ "user": {
+ "id": 123,
+ "name": "Ada"
+ },
+ "items": ["a", "b"]
+ },
+ "expected": "user:\n id: 123\n name: Ada\nitems[2]: a,b",
+ "specSection": "12",
+ "note": "2-space indentation, no trailing spaces on any line"
+ },
+ {
+ "name": "respects custom indent size option",
+ "input": {
+ "user": {
+ "name": "Ada",
+ "role": "admin"
+ }
+ },
+ "expected": "user:\n name: Ada\n role: admin",
+ "specSection": "12",
+ "options": {
+ "indent": 4
+ },
+ "note": "4-space indentation for nested objects when indent option is set to 4"
+ }
+ ]
+}
\ No newline at end of file