|
| 1 | +package dev.toonformat.jtoon.decoder; |
| 2 | + |
| 3 | +import java.util.ArrayList; |
| 4 | +import java.util.Collections; |
| 5 | +import java.util.List; |
| 6 | +import java.util.regex.Matcher; |
| 7 | + |
| 8 | +import static dev.toonformat.jtoon.util.Headers.ARRAY_HEADER_PATTERN; |
| 9 | +import static dev.toonformat.jtoon.util.Headers.TABULAR_HEADER_PATTERN; |
| 10 | + |
| 11 | +/** |
| 12 | + * Handles decoding of TOON arrays to JSON format. |
| 13 | + */ |
| 14 | +public class ArrayDecoder { |
| 15 | + |
| 16 | + private ArrayDecoder() { |
| 17 | + throw new UnsupportedOperationException("Utility class cannot be instantiated"); |
| 18 | + } |
| 19 | + |
| 20 | + /** |
| 21 | + * Parses array from header string and following lines. |
| 22 | + * Detects array type (tabular, list, or primitive) and routes accordingly. |
| 23 | + * @param header the header string to parse |
| 24 | + * @param depth the depth of array |
| 25 | + * @param context decode object in order to deal with lines, delimiter and options |
| 26 | + * @return parsed array with delimiter |
| 27 | + */ |
| 28 | + protected static List<Object> parseArray(String header, int depth, DecodeContext context) { |
| 29 | + String arrayDelimiter = extractDelimiterFromHeader(header, context); |
| 30 | + |
| 31 | + return parseArrayWithDelimiter(header, depth, arrayDelimiter, context); |
| 32 | + } |
| 33 | + |
| 34 | + /** |
| 35 | + * Extracts delimiter from array header. |
| 36 | + * Returns tab, pipe, or comma (default) based on header pattern. |
| 37 | + * @param header the header string to parse |
| 38 | + * @param context decode object in order to deal with lines, delimiter and options |
| 39 | + * @return extracted delimiter from header |
| 40 | + */ |
| 41 | + protected static String extractDelimiterFromHeader(String header, DecodeContext context) { |
| 42 | + Matcher matcher = ARRAY_HEADER_PATTERN.matcher(header); |
| 43 | + if (matcher.find() && matcher.groupCount() == 3) { |
| 44 | + String delimChar = matcher.group(3); |
| 45 | + if (delimChar != null) { |
| 46 | + if ("\t".equals(delimChar)) { |
| 47 | + return "\t"; |
| 48 | + } else if ("|".equals(delimChar)) { |
| 49 | + return "|"; |
| 50 | + } |
| 51 | + } |
| 52 | + } |
| 53 | + // Default to comma |
| 54 | + return context.delimiter; |
| 55 | + } |
| 56 | + |
| 57 | + /** |
| 58 | + * Parses array from header string and following lines with a specific |
| 59 | + * delimiter. |
| 60 | + * Detects array type (tabular, list, or primitive) and routes accordingly. |
| 61 | + * @param header the header string to parse |
| 62 | + * @param depth depth of array |
| 63 | + * @param arrayDelimiter array delimiter |
| 64 | + * @param context decode object in order to deal with lines, delimiter and options |
| 65 | + * @return parsed array |
| 66 | + */ |
| 67 | + protected static List<Object> parseArrayWithDelimiter(String header, int depth, String arrayDelimiter, DecodeContext context) { |
| 68 | + Matcher tabularMatcher = TABULAR_HEADER_PATTERN.matcher(header); |
| 69 | + Matcher arrayMatcher = ARRAY_HEADER_PATTERN.matcher(header); |
| 70 | + |
| 71 | + if (tabularMatcher.find()) { |
| 72 | + return TabularArrayDecoder.parseTabularArray(header, depth, arrayDelimiter, context); |
| 73 | + } |
| 74 | + |
| 75 | + if (arrayMatcher.find()) { |
| 76 | + int headerEndIdx = arrayMatcher.end(); |
| 77 | + String afterHeader = header.substring(headerEndIdx).trim(); |
| 78 | + |
| 79 | + if (afterHeader.startsWith(":")) { |
| 80 | + String inlineContent = afterHeader.substring(1).trim(); |
| 81 | + |
| 82 | + if (!inlineContent.isEmpty()) { |
| 83 | + List<Object> result = parseArrayValues(inlineContent, arrayDelimiter); |
| 84 | + validateArrayLength(header, result.size()); |
| 85 | + context.currentLine++; |
| 86 | + return result; |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + context.currentLine++; |
| 91 | + if (context.currentLine < context.lines.length) { |
| 92 | + String nextLine = context.lines[context.currentLine]; |
| 93 | + int nextDepth = DecodeHelper.getDepth(nextLine, context); |
| 94 | + String nextContent = nextLine.substring(nextDepth * context.options.indent()); |
| 95 | + |
| 96 | + if (nextDepth <= depth) { |
| 97 | + // The next line is not a child of this array, |
| 98 | + // the array is empty |
| 99 | + validateArrayLength(header, 0); |
| 100 | + return Collections.emptyList(); |
| 101 | + } |
| 102 | + |
| 103 | + if (nextContent.startsWith("- ")) { |
| 104 | + context.currentLine--; |
| 105 | + return parseListArray(depth, header, context); |
| 106 | + } else { |
| 107 | + context.currentLine++; |
| 108 | + List<Object> result = parseArrayValues(nextContent, arrayDelimiter); |
| 109 | + validateArrayLength(header, result.size()); |
| 110 | + return result; |
| 111 | + } |
| 112 | + } |
| 113 | + List<Object> empty = new ArrayList<>(); |
| 114 | + validateArrayLength(header, 0); |
| 115 | + return empty; |
| 116 | + } |
| 117 | + |
| 118 | + if (context.options.strict()) { |
| 119 | + throw new IllegalArgumentException("Invalid array header: " + header); |
| 120 | + } |
| 121 | + return Collections.emptyList(); |
| 122 | + } |
| 123 | + |
| 124 | + /** |
| 125 | + * Validates array length if declared in header. |
| 126 | + * @param header header |
| 127 | + * @param actualLength actual length |
| 128 | + */ |
| 129 | + protected static void validateArrayLength(String header, int actualLength) { |
| 130 | + Integer declaredLength = extractLengthFromHeader(header); |
| 131 | + if (declaredLength != null && declaredLength != actualLength) { |
| 132 | + throw new IllegalArgumentException( |
| 133 | + String.format("Array length mismatch: declared %d, found %d", declaredLength, actualLength)); |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + /** |
| 138 | + * Extracts declared length from array header. |
| 139 | + * Returns the number specified in [n] or null if not found. |
| 140 | + */ |
| 141 | + private static Integer extractLengthFromHeader(String header) { |
| 142 | + Matcher matcher = ARRAY_HEADER_PATTERN.matcher(header); |
| 143 | + if (matcher.find()) { |
| 144 | + try { |
| 145 | + return Integer.parseInt(matcher.group(2)); |
| 146 | + } catch (NumberFormatException e) { |
| 147 | + return null; |
| 148 | + } |
| 149 | + } |
| 150 | + return null; |
| 151 | + } |
| 152 | + |
| 153 | + /** |
| 154 | + * Parses array values from a delimiter-separated string. |
| 155 | + * @param values the values string to parse |
| 156 | + * @param arrayDelimiter array delimiter |
| 157 | + * @return parsed array values |
| 158 | + */ |
| 159 | + protected static List<Object> parseArrayValues(String values, String arrayDelimiter) { |
| 160 | + List<Object> result = new ArrayList<>(); |
| 161 | + List<String> rawValues = parseDelimitedValues(values, arrayDelimiter); |
| 162 | + for (String value : rawValues) { |
| 163 | + result.add(PrimitiveDecoder.parse(value)); |
| 164 | + } |
| 165 | + return result; |
| 166 | + } |
| 167 | + |
| 168 | + /** |
| 169 | + * Splits a string by delimiter, respecting quoted sections. |
| 170 | + * Whitespace around delimiters is tolerated and trimmed. |
| 171 | + * @param input the input string to parse |
| 172 | + * @param arrayDelimiter array delimiter |
| 173 | + * @return parsed delimited values |
| 174 | + */ |
| 175 | + protected static List<String> parseDelimitedValues(String input, String arrayDelimiter) { |
| 176 | + List<String> result = new ArrayList<>(); |
| 177 | + StringBuilder current = new StringBuilder(); |
| 178 | + boolean inQuotes = false; |
| 179 | + boolean escaped = false; |
| 180 | + char delimChar = arrayDelimiter.charAt(0); |
| 181 | + |
| 182 | + int i = 0; |
| 183 | + while (i < input.length()) { |
| 184 | + char c = input.charAt(i); |
| 185 | + |
| 186 | + if (escaped) { |
| 187 | + current.append(c); |
| 188 | + escaped = false; |
| 189 | + i++; |
| 190 | + } else if (c == '\\') { |
| 191 | + current.append(c); |
| 192 | + escaped = true; |
| 193 | + i++; |
| 194 | + } else if (c == '"') { |
| 195 | + current.append(c); |
| 196 | + inQuotes = !inQuotes; |
| 197 | + i++; |
| 198 | + } else if (c == delimChar && !inQuotes) { |
| 199 | + // Found delimiter - add current value (trimmed) and reset |
| 200 | + String value = current.toString().trim(); |
| 201 | + result.add(value); |
| 202 | + current = new StringBuilder(); |
| 203 | + // Skip whitespace after delimiter |
| 204 | + do { |
| 205 | + i++; |
| 206 | + } while (i < input.length() && Character.isWhitespace(input.charAt(i))); |
| 207 | + } else { |
| 208 | + current.append(c); |
| 209 | + i++; |
| 210 | + } |
| 211 | + } |
| 212 | + |
| 213 | + // Add final value |
| 214 | + if (!current.isEmpty() || input.endsWith(arrayDelimiter)) { |
| 215 | + result.add(current.toString().trim()); |
| 216 | + } |
| 217 | + |
| 218 | + return result; |
| 219 | + } |
| 220 | + |
| 221 | + /** |
| 222 | + * Parses list array format where items are prefixed with "- ". |
| 223 | + * Example: items[2]:\n - item1\n - item2 |
| 224 | + */ |
| 225 | + private static List<Object> parseListArray(int depth, String header, DecodeContext context) { |
| 226 | + List<Object> result = new ArrayList<>(); |
| 227 | + context.currentLine++; |
| 228 | + |
| 229 | + boolean shouldContinue = true; |
| 230 | + while (shouldContinue && context.currentLine < context.lines.length) { |
| 231 | + String line = context.lines[context.currentLine]; |
| 232 | + |
| 233 | + if (DecodeHelper.isBlankLine(line)) { |
| 234 | + if (handleBlankLineInListArray(depth, context)) { |
| 235 | + shouldContinue = false; |
| 236 | + } |
| 237 | + } else { |
| 238 | + int lineDepth = DecodeHelper.getDepth(line, context); |
| 239 | + if (shouldTerminateListArray(lineDepth, depth, line, context)) { |
| 240 | + shouldContinue = false; |
| 241 | + } else { |
| 242 | + ListItemDecoder.processListArrayItem(line, lineDepth, depth, result, context); |
| 243 | + } |
| 244 | + } |
| 245 | + } |
| 246 | + |
| 247 | + if (header != null) { |
| 248 | + ArrayDecoder.validateArrayLength(header, result.size()); |
| 249 | + } |
| 250 | + return result; |
| 251 | + } |
| 252 | + |
| 253 | + /** |
| 254 | + * Handles blank line processing in list array. |
| 255 | + * Returns true if array should terminate, false if line should be skipped. |
| 256 | + */ |
| 257 | + private static boolean handleBlankLineInListArray(int depth, DecodeContext context) { |
| 258 | + int nextNonBlankLine = DecodeHelper.findNextNonBlankLine(context.currentLine + 1, context); |
| 259 | + |
| 260 | + if (nextNonBlankLine >= context.lines.length) { |
| 261 | + return true; // End of file - terminate array |
| 262 | + } |
| 263 | + |
| 264 | + int nextDepth = DecodeHelper.getDepth(context.lines[nextNonBlankLine], context); |
| 265 | + if (nextDepth <= depth) { |
| 266 | + return true; // Blank line is outside array - terminate |
| 267 | + } |
| 268 | + |
| 269 | + // Blank line is inside array |
| 270 | + if (context.options.strict()) { |
| 271 | + throw new IllegalArgumentException("Blank line inside list array at line " + (context.currentLine + 1)); |
| 272 | + } |
| 273 | + // In non-strict mode, skip blank lines |
| 274 | + context.currentLine++; |
| 275 | + return false; |
| 276 | + } |
| 277 | + |
| 278 | + /** |
| 279 | + * Determines if list array parsing should terminate based online depth. |
| 280 | + * Returns true if array should terminate, false otherwise. |
| 281 | + */ |
| 282 | + private static boolean shouldTerminateListArray(int lineDepth, int depth, String line, DecodeContext context) { |
| 283 | + if (lineDepth < depth + 1) { |
| 284 | + return true; // Line depth is less than expected - terminate |
| 285 | + } |
| 286 | + // Also terminate if line is at expected depth but doesn't start with "-" |
| 287 | + if (lineDepth == depth + 1) { |
| 288 | + String content = line.substring((depth + 1) * context.options.indent()); |
| 289 | + return !content.startsWith("-"); // Not an array item - terminate |
| 290 | + } |
| 291 | + return false; |
| 292 | + } |
| 293 | +} |
0 commit comments