-
Notifications
You must be signed in to change notification settings - Fork 23
Expand file tree
/
Copy pathTabularArrayDecoder.java
More file actions
341 lines (307 loc) · 14.7 KB
/
Copy pathTabularArrayDecoder.java
File metadata and controls
341 lines (307 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
package dev.toonformat.jtoon.decoder;
import dev.toonformat.jtoon.Delimiter;
import dev.toonformat.jtoon.util.StringEscaper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import static dev.toonformat.jtoon.util.Constants.BACKSLASH;
import static dev.toonformat.jtoon.util.Constants.DOUBLE_QUOTE;
import static dev.toonformat.jtoon.util.Headers.TABULAR_HEADER_PATTERN;
/**
* Handles decoding of tabular arrays to JSON format.
*
* <p>In strict mode ({@code DecodeOptions.strict() == true}), each tabular row must contain exactly
* the same number of values as the header declares field keys, or an
* {@link IllegalArgumentException} is thrown.</p>
*
* <p>In lenient mode ({@code strict == false}), rows with fewer values than keys will have the
* missing keys silently omitted, and rows with more values than keys will have the extra values
* silently dropped. This means decoding can produce partial data without error when
* strict validation is disabled.</p>
*/
public final class TabularArrayDecoder {
private TabularArrayDecoder() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
/**
* Parses tabular array format where each row contains delimiter-separated
* values.
* Example: items[2]{id,name}:\n 1,Ada\n 2,Bob
*
* @param header the string representation of header
* @param depth depth of an array
* @param arrayDelimiter the type of delimiter used in the array
* @param context decode an object to deal with lines, delimiter and options
* @return tabular array converted to JSON format
*/
public static List<Object> parseTabularArray(final String header, final int depth, final Delimiter arrayDelimiter,
final DecodeContext context) {
final Matcher matcher = TABULAR_HEADER_PATTERN.matcher(header);
if (!matcher.find()) {
return Collections.emptyList();
}
final String keysStr = matcher.group(4);
final List<String> keys = parseTabularKeys(keysStr, arrayDelimiter, context);
final List<Object> result = new ArrayList<>();
context.currentLine++;
// Determine the expected row depth dynamically from the first non-blank line
int expectedRowDepth = depth + 1;
if (context.currentLine < context.lines.length) {
final int nextNonBlankLine = DecodeHelper.findNextNonBlankLine(context.currentLine, context);
if (nextNonBlankLine < context.lines.length) {
expectedRowDepth = DecodeHelper.getDepth(context.lines[nextNonBlankLine], context);
}
}
while (context.currentLine < context.lines.length) {
if (!processTabularArrayLine(expectedRowDepth, keys, arrayDelimiter, result, context)) {
break;
}
}
ArrayDecoder.validateArrayLength(header, result.size(), context.options.maxArraySize());
return Collections.unmodifiableList(result);
}
/**
* Parses tabular header keys from field specification.
* Validates delimiter consistency between bracket and brace fields.
*
* @param keysStr the string representation of keys
* @param arrayDelimiter the type of delimiter used in the array
* @param context decode an object to deal with lines, delimiter and options
* @return list of keys
*/
private static List<String> parseTabularKeys(final String keysStr, final Delimiter arrayDelimiter,
final DecodeContext context) {
// Validate delimiter mismatch between bracket and brace fields
if (context.options.strict()) {
validateKeysDelimiter(keysStr, arrayDelimiter);
}
final List<String> rawValues = ArrayDecoder.parseDelimitedValues(keysStr, arrayDelimiter);
final List<String> result = new ArrayList<>(rawValues.size());
for (final String key : rawValues) {
result.add(StringEscaper.unescape(key));
}
return result;
}
/**
* Validates delimiter consistency in tabular header keys.
*
* @param keysStr the string representation of keys
* @param expectedDelimiter the expected delimiter used in the array
*/
private static void validateKeysDelimiter(final String keysStr, final Delimiter expectedDelimiter) {
final char expectedChar = expectedDelimiter.toString().charAt(0);
boolean inQuotes = false;
boolean escaped = false;
for (int i = 0; i < keysStr.length(); i++) {
final char c = keysStr.charAt(i);
if (escaped) {
escaped = false;
} else if (c == BACKSLASH) {
escaped = true;
} else if (c == DOUBLE_QUOTE) {
inQuotes = !inQuotes;
} else if (!inQuotes) {
checkDelimiterMismatch(expectedChar, c);
}
}
}
/**
* Checks for delimiter mismatch and throws an exception if found.
*
* @param expectedChar the expected delimiter character
* @param actualChar the actual delimiter character
*/
private static void checkDelimiterMismatch(final char expectedChar, final char actualChar) {
if (expectedChar == Delimiter.TAB.getValue() && actualChar == Delimiter.COMMA.getValue()) {
throw new IllegalArgumentException("Delimiter mismatch: bracket declares tab (expected='"
+ expectedChar + "', actual='" + actualChar + "')");
}
if (expectedChar == Delimiter.PIPE.getValue() && actualChar == Delimiter.COMMA.getValue()) {
throw new IllegalArgumentException("Delimiter mismatch: bracket declares pipe (expected='"
+ expectedChar + "', actual='" + actualChar + "')");
}
if (expectedChar == Delimiter.COMMA.getValue()
&& (actualChar == Delimiter.TAB.getValue() || actualChar == Delimiter.PIPE.getValue())) {
throw new IllegalArgumentException(
"Delimiter mismatch: bracket declares comma, brace fields use different delimiter");
}
}
/**
* Processes a single line in a tabular array.
*
* @param expectedRowDepth the expected depth of the next row
* @param keys the keys for the tabular array
* @param arrayDelimiter the type of delimiter used in the array
* @param result the list to store parsed rows in
* @param context decode an object to deal with lines, delimiter and options
* @return true if parsing should continue, false if an array should terminate
*/
private static boolean processTabularArrayLine(final int expectedRowDepth, final List<String> keys,
final Delimiter arrayDelimiter, final List<Object> result,
final DecodeContext context) {
final String line = context.lines[context.currentLine];
if (DecodeHelper.isBlankLine(line)) {
return !handleBlankLineInTabularArray(expectedRowDepth, context);
}
final int lineDepth = DecodeHelper.getDepth(line, context);
if (shouldTerminateTabularArray(line, lineDepth, expectedRowDepth, context)) {
return false;
}
if (processTabularRow(line, lineDepth, expectedRowDepth, keys, arrayDelimiter, result, context)) {
context.currentLine++;
}
return true;
}
/**
* Handles blank line processing in a tabular array.
*
* @param expectedRowDepth the expected depth of the next row
* @param context decode an object to deal with lines, delimiter and options
* @return true if an array should terminate, false if a line should be skipped
*/
private static boolean handleBlankLineInTabularArray(final int expectedRowDepth, final DecodeContext context) {
final int nextNonBlankLine = DecodeHelper.findNextNonBlankLine(context.currentLine + 1, context);
if (nextNonBlankLine < context.lines.length) {
final int nextDepth = DecodeHelper.getDepth(context.lines[nextNonBlankLine], context);
// Header depth is one level above the expected row depth
final int headerDepth = expectedRowDepth - 1;
if (nextDepth <= headerDepth) {
return true;
}
}
// Blank line is inside the array
if (context.options.strict()) {
throw new IllegalArgumentException(
"Blank line inside tabular array at line " + (context.currentLine + 1));
}
// In non-strict mode, skip blank lines
context.currentLine++;
return false;
}
/**
* Determines if tabular array parsing should terminate based on online depth.
* Implements the full disambiguation algorithm per spec §9.3:
* - Compute the first unquoted occurrence of the active delimiter and the first unquoted colon.
* - If a same-depth line has no unquoted colon → row.
* - If both appear, compare first-unquoted positions:
* - Delimiter before colon → row.
* - Colon before delimiter → key-value line (end of rows).
* - If a line has an unquoted colon but no unquoted active delimiter → key-value line.
*
* @param line the line to check
* @param lineDepth the depth of the line
* @param expectedRowDepth the expected depth of the next row
* @param context decode an object to deal with lines, delimiter and options
* @return true if an array should terminate, false otherwise.
*/
private static boolean shouldTerminateTabularArray(final String line, final int lineDepth,
final int expectedRowDepth, final DecodeContext context) {
final int headerDepth = expectedRowDepth - 1;
if (lineDepth < expectedRowDepth) {
if (lineDepth == headerDepth) {
final String content = line.substring(headerDepth * context.options.indent());
final int colonIdx = DecodeHelper.findUnquotedColon(content);
if (colonIdx > 0) {
return true; // Key-value pair at the same depth-terminate an array
}
}
return true; // Line depth is less than expected - terminate
}
if (lineDepth != expectedRowDepth) {
return false;
}
// Spec §9.3 disambiguation at row depth
final String rowContent = line.substring(expectedRowDepth * context.options.indent());
final char delimChar = context.delimiter.getValue();
final int delimIdx = findFirstUnquoted(rowContent, delimChar);
final int colonIdx = DecodeHelper.findUnquotedColon(rowContent);
if (colonIdx < 0) {
return false; // No colon → this is a row
}
if (delimIdx < 0) {
return true; // Colon present, no delimiter → key-value line
}
// Both colon and delimiter present: compare positions
return colonIdx < delimIdx; // Colon first → key-value; delimiter first → row
}
/**
* Finds the index of the first unquoted occurrence of a character in a string.
*/
private static int findFirstUnquoted(final String content, final char target) {
boolean inQuotes = false;
boolean escaped = false;
for (int i = 0; i < content.length(); i++) {
final char c = content.charAt(i);
if (escaped) {
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
inQuotes = !inQuotes;
} else if (!inQuotes && c == target) {
return i;
}
}
return -1;
}
/**
* Processes a tabular row if it matches the expected depth.
*
* @param line the line to process
* @param lineDepth the depth of the line
* @param expectedRowDepth the expected depth of the next row
* @param keys the keys for the tabular array
* @param arrayDelimiter the type of delimiter used in the array
* @param result the list to store parsed rows in
* @param context decode an object to deal with lines, delimiter and options
* @return true if a line was processed and the currentLine should be incremented, false otherwise.
*/
private static boolean processTabularRow(final String line, final int lineDepth,
final int expectedRowDepth, final List<String> keys, final Delimiter arrayDelimiter,
final List<Object> result, final DecodeContext context) {
if (lineDepth == expectedRowDepth) {
final String rowContent = line.substring(expectedRowDepth * context.options.indent());
final Map<String, Object> row = parseTabularRow(rowContent, keys, arrayDelimiter, context);
result.add(row);
return true;
} else if (lineDepth > expectedRowDepth) {
// Line is deeper than expected - might be nested content, skip it
context.currentLine++;
return false;
}
return true;
}
/**
* Parses a tabular row into a Map using the provided keys.
* Validates that the row uses the correct delimiter.
*
* <p>In strict mode, the number of values must exactly match the number of keys.
* In lenient mode, excess values are silently dropped and missing values
* result in omitted keys.</p>
*
* @param rowContent the row content to parse
* @param keys the keys for the tabular array
* @param arrayDelimiter the type of delimiter used in the array
* @param context decode an object to deal with lines, delimiter and options
* @return a Map containing the parsed row values
*/
private static Map<String, Object> parseTabularRow(final String rowContent, final List<String> keys,
final Delimiter arrayDelimiter, final DecodeContext context) {
final Map<String, Object> row = new LinkedHashMap<>();
final List<Object> values = ArrayDecoder.parseArrayValues(rowContent, arrayDelimiter,
context.options.maxArraySize(), context.options.maxStringLength());
// Validate value count matches key count
if (context.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));
}
return row;
}
}