001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.marshall.toml; 018 019import static org.apache.juneau.commons.utils.CollectionUtils.isEmpty; 020import static org.apache.juneau.commons.utils.Shorts.*; 021import static org.apache.juneau.commons.utils.StringUtils.isEmpty; 022 023import java.io.*; 024import java.time.temporal.*; 025import java.util.*; 026import java.util.Map.*; 027 028import org.apache.juneau.commons.bean.*; 029import org.apache.juneau.commons.reflect.*; 030import org.apache.juneau.marshall.*; 031import org.apache.juneau.marshall.collections.*; 032import org.apache.juneau.marshall.parser.*; 033import org.apache.juneau.marshall.stream.*; 034 035/** 036 * Session for parsing TOML into POJOs. 037 */ 038/* 039 * Suppression rationale: 040 * - rawtypes, unchecked: TOML maps use Map<String,Object> with dynamic values requiring casts. 041 * - java:S3776: Cognitive complexity; parser logic is inherently branched for TOML syntax. 042 * - java:S6541: Brain method; parser orchestration spans many concerns. 043 * - java:S135: Multiple break/continue per loop needed for TOML state-machine parsing. 044 * - java:S1192: Duplicated literals (argument/property names) read more clearly inline than as constants. 045 */ 046@SuppressWarnings({ 047 "java:S135", // Multiple breaks acceptable in parse loop 048 "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants 049 "java:S1612", // Class.isAssignableFrom necessary when checking target type (Class<?>), not object instance 050 "java:S3776", // Cognitive complexity acceptable for readMessage 051 "java:S6541", // Brain method acceptable for readMessage 052 "rawtypes", // Raw types necessary for generic Map/List handling 053 "resource", // Closeable resources are owned by the caller's parser session; Eclipse JDT @Owning warning is by design. 054 "unchecked" // Type erasure requires unchecked casts in convertValue 055}) 056public class TomlParserSession extends ReaderParserSession implements RecordReadable { 057 058 private static final String MSG_INVALID_NUMBER = "Invalid number: "; 059 060 /** 061 * Builder for TOML parser session. 062 */ 063 public static class Builder extends ReaderParserSession.Builder<Builder> { 064 065 private final TomlParser ctx; 066 067 protected Builder(TomlParser ctx) { 068 super(reqnn("ctx", ctx)); 069 this.ctx = ctx; 070 } 071 072 @Override 073 public TomlParserSession build() { 074 return new TomlParserSession(this); 075 } 076 } 077 078 /** 079 * Creates a session builder. 080 * 081 * @param ctx The parser context. 082 * <br>Cannot be <jk>null</jk>. 083 * @return The builder. 084 */ 085 public static Builder create(TomlParser ctx) { 086 return new Builder(reqnn("ctx", ctx)); 087 } 088 089 private final TomlParser ctx; 090 091 protected TomlParserSession(Builder builder) { 092 super(builder); 093 ctx = builder.ctx; 094 } 095 096 @Override /* RecordReadable */ 097 public RecordReader readRecords(Object input) throws IOException { 098 return RecordAdapter.reader(this, input); 099 } 100 101 @Override /* RecordReadable */ 102 public boolean isRecordStreaming() { 103 return false; 104 } 105 106 @Override 107 protected <T> T doRead(ParserPipe pipe, ClassMeta<T> type) throws IOException, ParseException, ExecutableException { 108 try (Reader r = pipe.getParserReader()) { 109 if (r == null) 110 return null; 111 Map<String,Object> root = readTomlDocument(new TomlTokenizer(r)); 112 if (isEmpty(root)) 113 return type.canCreateNewBean(getOuter()) ? type.newInstance(getOuter()) : null; 114 // Root _value wrapper: document has exactly one key _value 115 if (root.size() == 1 && root.containsKey("_value")) { 116 Object inner = root.get("_value"); 117 return (T) convertValue(inner, type); 118 } 119 return convertMapToType(root, type); 120 } 121 } 122 123 private Map<String,Object> readTomlDocument(TomlTokenizer t) throws IOException, ParseException { 124 Map<String,Object> root = m(); 125 Map<String,Object> currentTable = root; 126 127 while (!t.isEof()) { 128 t.skipWhitespaceAndComments(); 129 if (t.isEof()) 130 break; 131 132 int c = t.peek(); 133 if (c == '[') { 134 t.read(); 135 if (t.peek() == '[') { 136 t.read(); 137 String path = readTablePath(t); 138 Object existing = getOrCreateAt(root, path); 139 if (!(existing instanceof List)) { 140 if (existing != null) 141 throw t.parseException("Key " + path + " redefined"); 142 setAt(root, path, l()); 143 existing = getOrCreateAt(root, path); 144 } 145 Map<String,Object> newTable = m(); 146 ((List)existing).add(newTable); 147 currentTable = newTable; 148 } else { 149 String path = readTablePath(t); 150 currentTable = getOrCreateTableAt(root, path); 151 } 152 } else if (c == '=' || Character.isLetterOrDigit(c) || c == '"' || c == '\'' || c == '_' || c == '-') { 153 List<String> keyPath = readKey(t); 154 if (keyPath.isEmpty()) 155 throw t.parseException("Expected key"); 156 t.skipWhitespace(); 157 if (t.read() != '=') 158 throw t.parseException("Expected '='"); 159 t.skipWhitespace(); 160 Object value = readValue(t); 161 setValueAt(currentTable, keyPath, value); 162 } else { 163 break; 164 } 165 } 166 return root; 167 } 168 169 private static String readTablePath(TomlTokenizer t) throws IOException, ParseException { 170 t.skipWhitespace(); 171 var parts = new ArrayList<String>(); 172 while (true) { 173 String key = t.readBareKey(); 174 if (key == null) 175 break; 176 parts.add(key); 177 t.skipWhitespace(); 178 if (t.peek() != '.') 179 break; 180 t.read(); 181 t.skipWhitespace(); 182 } 183 if (t.peek() == ']') 184 t.read(); 185 if (t.peek() == ']') 186 t.read(); 187 return String.join(".", parts); 188 } 189 190 private static List<String> readKey(TomlTokenizer t) throws IOException, ParseException { 191 var parts = new ArrayList<String>(); 192 while (true) { 193 String key = t.readBareKey(); 194 if (key == null) 195 break; 196 parts.add(key); 197 t.skipWhitespace(); 198 if (t.peek() != '.') 199 break; 200 t.read(); 201 t.skipWhitespace(); 202 } 203 return parts; 204 } 205 206 private Object readValue(TomlTokenizer t) throws IOException, ParseException { 207 int c = t.peek(); 208 if (c == '"') { 209 if (isTripleQuote(t, '"')) 210 return t.readMultiLineBasicString(); 211 return t.readBasicString(); 212 } 213 if (c == '\'') { 214 if (isTripleQuote(t, '\'')) 215 return t.readMultiLineLiteralString(); 216 return t.readLiteralString(); 217 } 218 if (c == 't' || c == 'f') 219 return t.readBoolean(); 220 if (c == '[') { 221 t.read(); 222 t.skipWhitespaceAndComments(); 223 var list = l(); 224 while (t.peek() != ']') { 225 list.add(readValue(t)); 226 t.skipWhitespaceAndComments(); 227 if (t.peek() == ',') { 228 t.read(); 229 t.skipWhitespaceAndComments(); 230 } 231 } 232 t.read(); 233 return list; 234 } 235 if (c == '{') { 236 t.read(); 237 t.skipWhitespace(); 238 var map = new LinkedHashMap<String,Object>(); 239 while (t.peek() != '}') { 240 t.skipWhitespace(); 241 List<String> k = readKey(t); 242 if (k.isEmpty()) 243 throw t.parseException("Expected key in inline table"); 244 t.skipWhitespace(); 245 if (t.read() != '=') 246 throw t.parseException("Expected '='"); 247 t.skipWhitespace(); 248 Object v = readValue(t); 249 map.put(k.get(0), v); 250 t.skipWhitespace(); 251 if (t.peek() == ',') { 252 t.read(); 253 t.skipWhitespace(); 254 } 255 } 256 t.read(); 257 return map; 258 } 259 if (Character.isDigit(c) || c == '+' || c == '-') { 260 String s = readUntilValueEnd(t); 261 if (isEmpty(s)) 262 throw t.parseException("Expected value"); 263 Number special = readSpecialFloat(s); 264 if (special != null) 265 return special; 266 Number radix = readRadixInteger(s, t); 267 if (radix != null) 268 return radix; 269 // TOML permits a space in place of 'T' between the date and time portions of an 270 // offset/local date-time (e.g. "2024-01-15 10:30:00"). readUntilValueEnd() above hard-stops 271 // at the first space -- a legitimate terminator for every other TOML value shape -- so 272 // without this, the time portion is silently truncated and the bare date string is returned 273 // instead. If what's been read so far is exactly a bare date and it's immediately followed by 274 // " <digit>", that space belongs to *this* value: consume it and keep reading the time part. 275 if (s.length() == 10 && s.charAt(4) == '-' && s.charAt(7) == '-' && t.peek() == ' ') { 276 t.read(); 277 if (Character.isDigit(t.peek())) { 278 s = s + " " + readUntilValueEnd(t); 279 } else { 280 t.unread(' '); 281 } 282 } 283 String noUnderscore = s.replace("_", ""); 284 if (s.length() >= 10 && s.charAt(4) == '-' && (s.contains("T") || s.contains(":") || s.contains(" "))) 285 return TomlTokenizer.parseDateTimeString(s); 286 // Date-like patterns kept as string for convertValue to parse as LocalDate, YearMonth, Year 287 if (isDateLikeString(noUnderscore)) 288 return s; 289 if (s.contains(".") || s.toLowerCase(Locale.ROOT).contains("e")) 290 return Double.parseDouble(noUnderscore); 291 try { 292 return Long.parseLong(noUnderscore); 293 } catch (@SuppressWarnings("unused") NumberFormatException e) { 294 // May be date/time string that slipped through (e.g. LocalDate, LocalTime) 295 if (s.contains("-") || s.contains(":")) 296 return s; 297 throw t.parseException(MSG_INVALID_NUMBER + s); 298 } 299 } 300 if (c == 'i' || c == 'n') { 301 String s = readUntilValueEnd(t); 302 Number special = readSpecialFloat(s); 303 if (special != null) 304 return special; 305 throw t.parseException("Expected inf or nan"); 306 } 307 throw t.parseException("Unexpected character: " + (char)c); 308 } 309 310 private static boolean isTripleQuote(TomlTokenizer t, char q) throws IOException { 311 if (t.peek() != q) return false; 312 int c1 = t.read(); 313 int c2 = t.read(); 314 int c3 = t.peek(); 315 if (c2 == q && c3 == q) { 316 t.read(); 317 return true; 318 } 319 if (c2 >= 0) 320 t.unread(c2); 321 t.unread(c1); 322 return false; 323 } 324 325 private static boolean isDateLikeString(String s) { 326 if (s == null || s.length() < 7) 327 return false; 328 // yyyy-MM-dd (LocalDate) | yyyy-MM (YearMonth) - exclude plain 4-digit (e.g. port numbers) 329 return (s.length() == 10 && s.charAt(4) == '-' && s.charAt(7) == '-') 330 || (s.length() == 7 && s.charAt(4) == '-'); 331 } 332 333 private static String readUntilValueEnd(TomlTokenizer t) throws IOException { 334 var sb = new StringBuilder(); 335 int c; 336 while ((c = t.peek()) >= 0 && c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '#' && c != ',' && c != ']' && c != '}') { 337 sb.append((char) t.read()); 338 } 339 return sb.isEmpty() ? null : sb.toString().trim(); 340 } 341 342 private static Number readSpecialFloat(String s) { 343 if (s == null) 344 return null; 345 if (eqa(s, "inf", "+inf")) 346 return Double.POSITIVE_INFINITY; 347 if (eq(s, "-inf")) 348 return Double.NEGATIVE_INFINITY; 349 if (eqa(s, "nan", "+nan", "-nan")) 350 return Double.NaN; 351 return null; 352 } 353 354 private static Number readRadixInteger(String s, TomlTokenizer t) throws ParseException { 355 if (s == null || s.length() < 3 || s.charAt(0) != '0') 356 return null; 357 char p = s.charAt(1); 358 int radix; 359 if (p == 'x' || p == 'X') 360 radix = 16; 361 else if (p == 'o' || p == 'O') 362 radix = 8; 363 else if (p == 'b' || p == 'B') 364 radix = 2; 365 else 366 return null; 367 String digits = s.substring(2).replace("_", ""); 368 if (digits.isEmpty()) 369 throw t.parseException(MSG_INVALID_NUMBER + s); 370 try { 371 return Long.parseUnsignedLong(digits, radix); 372 } catch (@SuppressWarnings("unused") NumberFormatException e) { 373 throw t.parseException(MSG_INVALID_NUMBER + s); 374 } 375 } 376 377 // Steps into a child table. Per TOML, a header path that traverses an array of tables refers to its most recent element. 378 private static Object stepInto(Object current, String key) { 379 if (current instanceof List l && !l.isEmpty()) 380 current = l.get(l.size() - 1); 381 return ((Map)current).computeIfAbsent(key, k -> new LinkedHashMap<String,Object>()); 382 } 383 384 private static Map<String,Object> lastTable(Object current) { 385 if (current instanceof List l && !l.isEmpty()) 386 current = l.get(l.size() - 1); 387 return (Map<String,Object>)current; 388 } 389 390 private static Object getOrCreateAt(Map<String,Object> root, String path) { 391 String[] parts = path.split("\\."); 392 Object current = root; 393 for (int i = 0; i < parts.length - 1; i++) 394 current = stepInto(current, parts[i]); 395 return lastTable(current).get(parts[parts.length - 1]); 396 } 397 398 private static Map<String,Object> getOrCreateTableAt(Map<String,Object> root, String path) { 399 String[] parts = path.split("\\."); 400 Object current = root; 401 for (String part : parts) 402 current = stepInto(current, part); 403 return lastTable(current); 404 } 405 406 private static void setAt(Map<String,Object> root, String path, Object value) { 407 String[] parts = path.split("\\."); 408 Object current = root; 409 for (int i = 0; i < parts.length - 1; i++) 410 current = stepInto(current, parts[i]); 411 lastTable(current).put(parts[parts.length - 1], value); 412 } 413 414 private static void setValueAt(Map<String,Object> root, List<String> keyPath, Object value) { 415 if (keyPath.size() == 1) { 416 root.put(keyPath.get(0), value); 417 return; 418 } 419 Object current = root; 420 for (int i = 0; i < keyPath.size() - 1; i++) { 421 current = ((Map)current).computeIfAbsent(keyPath.get(i), k -> new LinkedHashMap<String,Object>()); 422 } 423 ((Map)current).put(keyPath.get(keyPath.size() - 1), value); 424 } 425 426 private <T> T convertMapToType(Map<String,Object> map, ClassMeta<T> type) throws ParseException, ExecutableException { 427 if (type.isMap()) { 428 var keyType = type.getKeyType(); 429 var valueType = type.getValueType(); 430 Map m = type.canCreateNewInstance(getOuter()) ? (Map) type.newInstance(getOuter()) : newGenericMap(type); 431 for (var e : map.entrySet()) { 432 String keyStr = eq(e.getKey(), "null") ? null : e.getKey(); 433 Object key = convertAttrToType(m, keyStr, keyType); 434 Object val = convertValue(e.getValue(), valueType); 435 m.put(key, val); 436 } 437 return (T) m; 438 } 439 BeanMap<?> bm = toBeanMap(type.newInstance(getOuter())); 440 populateBeanMap(bm, map); 441 return (T) checkRequired(bm).getBean(); 442 } 443 444 private JsonMap toJsonMap(Map<?,?> map) throws ParseException, ExecutableException { 445 var jm = new JsonMap(); 446 for (Entry<?,?> e : map.entrySet()) { 447 Object k = e.getKey(); 448 Object v = e.getValue(); 449 Object converted = v instanceof Map v2 ? toJsonMap(v2) : convertValue(v, object()); 450 jm.put(k == null ? "null" : k.toString(), converted); 451 } 452 return jm; 453 } 454 455 private void populateBeanMap(BeanMap<?> bm, Map<String,Object> map) throws ParseException, ExecutableException { 456 for (Entry<String,Object> e : map.entrySet()) { 457 String key = e.getKey(); 458 Object val = e.getValue(); 459 BeanPropertyMeta pMeta = bm.getMeta().getProperties().get(key); 460 if (pMeta == null && isIgnoreUnknownBeanProperties()) 461 continue; 462 if (pMeta == null) 463 throw new ParseException(this, "Unknown property '%s'", key); 464 ClassMeta<?> targetType = (ClassMeta<?>) pMeta.getBeanInfo(); 465 Object converted = convertValue(val, targetType); 466 bm.put(key, converted); 467 } 468 } 469 470 @SuppressWarnings({ 471 "java:S9391" // Hot path; avoids stream allocation (and loop body throws checked exceptions). 472 }) 473 private Object convertValue(Object val, ClassMeta<?> targetType) throws ParseException, ExecutableException { 474 if (val == null) 475 return null; 476 String nv = ctx != null ? ctx.getNullValue() : null; 477 if (val instanceof String val2 && nv != null && val2.equals(nv)) 478 return null; 479 if (val instanceof Map val2) { 480 if (JsonMap.class.isAssignableFrom(targetType.inner())) 481 return toJsonMap(val2); 482 if (targetType.isBean()) { 483 BeanMap<?> child = toBeanMap(targetType.newInstance(getOuter())); 484 populateBeanMap(child, val2); 485 return checkRequired(child).getBean(); 486 } 487 } 488 if (val instanceof List val2 && targetType.isCollectionOrArray()) { 489 ClassMeta<?> elType = targetType.getElementType(); 490 List result = new ArrayList(); 491 for (Object item : val2) { 492 result.add(convertValue(item, elType)); 493 } 494 return targetType.isArray() ? toArray(targetType, result) : result; 495 } 496 // Bare numeric wire literals (e.g. "2024", "8100000000000") arrive here as Number values 497 // from the TOML tokenizer. Route them through the format-aware parsers so the configured 498 // MarshallingContext.get<Format>() hint (NANOS, ISO_YEAR, MILLIS, …) reaches the coercion. 499 // Without this routing the generic Number → T coercion below would silently drop the hint 500 // (e.g. treating Long(2024) as epoch-millis → 1970-01-01T00:00:02.024Z instead of year 2024). 501 if (val instanceof Number val2) { 502 if (targetType.isNumber()) 503 return convertToMemberType(null, val2, targetType); 504 if (targetType.isDuration()) 505 return readDuration(val2.toString()); 506 if (targetType.isPeriod()) 507 return readPeriod(val2.toString()); 508 if (targetType.isDate()) 509 return readDate(val2.toString(), targetType); 510 if (targetType.isCalendar()) 511 return readCalendar(val2.toString(), targetType); 512 if (targetType.isTemporal()) 513 return readTemporal(val2.toString(), targetType); 514 } 515 if (val instanceof String val2) { 516 if (targetType.isDate()) 517 return readDate(val2, targetType); 518 if (targetType.isCalendar()) 519 return readCalendar(val2, targetType); 520 if (targetType.isTemporal()) 521 return readTemporal(val2, targetType); 522 if (targetType.isDuration()) 523 return readDuration(val2); 524 if (targetType.isPeriod()) 525 return readPeriod(val2); 526 } 527 // Native TOML datetime literals (Z-zoned / offset / local) are returned by TomlTokenizer as 528 // java.time.OffsetDateTime / LocalDateTime / LocalDate / LocalTime objects. Re-stringify 529 // through the temporal's own ISO toString() and route through readTemporal so the 530 // configured TemporalFormat hint (e.g. ISO_INSTANT) is honored — otherwise the generic 531 // Temporal → T coercion below applies the local zone and we lose the wire's UTC anchor. 532 if (val instanceof TemporalAccessor val2 && targetType.isTemporal()) 533 return readTemporal(val2.toString(), targetType); 534 // Route String-shaped byte[] values (collection-element + top-level dispatch sites) through 535 // the configured BinaryFormat's variant binarySwap before falling through to the default 536 // String → byte[] coercion. The bean-property path is unaffected because the per-property 537 // MPP install hides the type-level default swap behind the per-property swap. 538 var binBytes = tryUnswapByteArray(val, targetType); 539 if (binBytes != null) 540 return binBytes; 541 return convertToMemberType(null, val, targetType); 542 } 543 544 /** 545 * Routes a {@link String}-shaped {@code byte[]} value through the session-aware 546 * {@link org.apache.juneau.marshall.swaps.BinarySwap} when one is matched. 547 * 548 * <p>Collection-element and top-level dispatch sites don't go through 549 * {@code MarshalledPropertyPostProcessor}'s per-property swap install, so they fall back to the 550 * {@link org.apache.juneau.marshall.swap.spi.DefaultSwaps} registry. Returns {@code null} when the target isn't 551 * {@code byte[]}, the value isn't a {@link String}, or no swap is matched on the current session 552 * (e.g. {@link BinaryFormat#NOT_SET} is configured). 553 */ 554 @SuppressWarnings({ 555 "java:S1168" // Null is a meaningful 'no byte[] unswap applies' signal; callers fall through on null (an empty array would wrongly mean 'unswapped to empty bytes'). 556 }) 557 private byte[] tryUnswapByteArray(Object val, ClassMeta<?> targetType) throws ParseException { 558 if (!(val instanceof String val2) || targetType == null || targetType.inner() != byte[].class) 559 return null; 560 var swap = targetType.getSwap(this); 561 if (swap == null) 562 return null; 563 return (byte[]) unswap(swap, val2, targetType); 564 } 565}