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: *

* * *

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:

* * * @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