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.markdown; 018 019import static org.apache.juneau.commons.function.Suppliers.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.io.*; 023import java.util.*; 024 025import org.apache.juneau.commons.bean.*; 026import org.apache.juneau.commons.function.*; 027import org.apache.juneau.marshall.*; 028import org.apache.juneau.marshall.json.*; 029import org.apache.juneau.marshall.json5.*; 030import org.apache.juneau.marshall.parser.*; 031import org.apache.juneau.marshall.stream.*; 032import org.apache.juneau.marshall.swap.spi.*; 033 034/** 035 * Session object for {@link MarkdownParser}. 036 * 037 * <p> 038 * Parses Markdown tables and bulleted lists into Java objects. 039 * 040 * <h5 class='section'>Notes:</h5><ul> 041 * <li class='warn'>This class is not thread safe and is typically discarded after one use. 042 * </ul> 043 * 044 */ 045@SuppressWarnings({ 046 "java:S110", // Inheritance depth acceptable for parser session hierarchy 047 "java:S115", // Constants use UPPER_snakeCase convention 048 "java:S1192", // Duplicated literals read more clearly inline than as constants. 049 "java:S3740", // Raw Map needed for generic map construction from ClassMeta 050 "java:S3776", // Cognitive complexity acceptable for doRead / readAnything 051 "java:S6541", // Brain method acceptable for readAnything 052 "rawtypes", // Raw Map is used where the map is created from ClassMeta and its key/value types are only known at runtime 053 "resource", // Closeable resources are owned by the caller's parser session; Eclipse JDT @Owning warning is by design. 054 "unchecked" // (T) casts of parsed cell values and (Collection<Object>) casts of eType.newInstance() match the requested ClassMeta eType 055}) 056public class MarkdownParserSession extends ReaderParserSession implements RecordReadable { 057 058 final String nullValue; 059 private final Memoizer<JsonParser> json5Parser = memoizer(() -> { 060 var b = Json5Parser.create().marshallingContext((MarshallingContext) getContext()); 061 if (isTrimStrings()) 062 b = b.trimStrings(); 063 return b.build(); 064 }); 065 066 /** 067 * Builder class. 068 */ 069 @SuppressWarnings({ 070 "java:S119" // 'SELF' (CRTP self-type) is intentional and clearer than a single-letter name. 071 }) 072 public abstract static class Builder<SELF extends Builder<SELF>> extends ReaderParserSession.Builder<SELF> { 073 074 String nullValue; 075 076 /** 077 * Constructor. 078 * 079 * @param ctx The context creating this session. 080 */ 081 protected Builder(MarkdownParser ctx) { 082 super(ctx); 083 nullValue = ctx.getNullValue(); 084 } 085 086 @Override 087 public MarkdownParserSession build() { 088 return new MarkdownParserSession(this); 089 } 090 091 } 092 093 /** 094 * Concrete default builder leaf for the non-subclassed {@code create()} path (CRTP terminal). 095 */ 096 public static final class DefaultBuilder extends Builder<DefaultBuilder> { 097 098 DefaultBuilder(MarkdownParser ctx) { 099 super(ctx); 100 } 101 } 102 103 /** 104 * Creates a new builder for this object. 105 * 106 * @param ctx The context creating this session. 107 * <br>Cannot be <jk>null</jk>. 108 * @return A new builder. 109 */ 110 @SuppressWarnings({ 111 "java:S1452" // Builder<?> wildcard return intentional; callers use it to construct session instances polymorphically 112 }) 113 public static Builder<?> create(MarkdownParser ctx) { 114 return new DefaultBuilder(ctx); 115 } 116 117 /** 118 * Constructor. 119 * 120 * @param builder The builder for this object. 121 */ 122 protected MarkdownParserSession(Builder<?> builder) { 123 super(builder); 124 nullValue = or(builder.nullValue, "*null*"); 125 } 126 127 @Override /* RecordReadable */ 128 public RecordReader readRecords(Object input) throws IOException { 129 return RecordAdapter.reader(this, input); 130 } 131 132 @Override /* RecordReadable */ 133 public boolean isRecordStreaming() { 134 return false; 135 } 136 137 @Override /* Overridden from ParserSession */ 138 protected <T> T doRead(ParserPipe pipe, ClassMeta<T> type) throws IOException, ParseException { 139 try (var r = new BufferedReader(pipe.getReader())) { 140 var lines = readAllLines(r); 141 return readAnything(lines, type, getOuter(), null); 142 } 143 } 144 145 /** 146 * Main parse dispatch method. 147 * 148 * @param <T> The target type. 149 * @param lines All input lines. 150 * @param eType The expected type. 151 * @param outer The outer object (for inner class instantiation). 152 * @param pMeta The parent bean property meta (may be null). 153 * @return The parsed object. 154 * @throws IOException Thrown by underlying stream. 155 * @throws ParseException If parsing fails. 156 */ 157 protected <T> T readAnything(List<String> lines, ClassMeta<T> eType, Object outer, BeanPropertyMeta pMeta) throws IOException, ParseException { 158 if (eType == null) 159 eType = (ClassMeta<T>) object(); 160 161 var swap = (ObjectSwap<T,Object>) eType.getSwap(this); 162 var builder = (BuilderSwap<T,Object>) eType.getBuilderSwap(this); 163 ClassMeta<?> sType; 164 if (builder != null) 165 sType = builder.getBuilderClassMeta(this); 166 else if (swap != null) 167 sType = swap.getSwapClassMeta(this); 168 else 169 sType = eType; 170 171 if (sType.isOptional()) 172 return (T) o(readAnything(lines, eType.getElementType(), outer, pMeta)); 173 174 // Skip blank lines and handle empty input 175 var nonBlank = lines.stream().filter(l -> !l.isBlank()).toList(); 176 if (lines.isEmpty() || nonBlank.isEmpty()) 177 return sType.isString() ? (T) "" : null; 178 179 var firstNonBlank = nonBlank.get(0).trim(); 180 181 Object o; 182 183 if (firstNonBlank.startsWith("|")) { 184 // Table input 185 o = readTable(lines, sType, outer); 186 } else if (firstNonBlank.startsWith("- ") || firstNonBlank.startsWith("* ") || firstNonBlank.startsWith("+ ")) { 187 // Bulleted list input 188 o = readBulletList(lines, sType, outer); 189 } else { 190 // Plain text — treat as simple value 191 o = readCellValue(firstNonBlank, sType, null); 192 } 193 194 if (builder != null && o != null) 195 o = builder.build(this, o, eType); 196 197 if (swap != null && o != null) 198 o = unswap(swap, o, eType); 199 200 return (T) o; 201 } 202 203 /** 204 * Parses a Markdown table (either 2-column key/value or multi-column). 205 * 206 * @param lines All input lines. 207 * @param eType The expected type. 208 * @param outer The outer object. 209 * @return The parsed object. 210 * @throws ParseException If parsing fails. 211 */ 212 protected Object readTable(List<String> lines, ClassMeta<?> eType, Object outer) throws ParseException { 213 // Extract table rows (lines starting with |) 214 var tableLines = lines.stream().filter(l -> l.trim().startsWith("|")).toList(); 215 if (tableLines.isEmpty()) 216 return null; 217 218 // Parse header row 219 var headerLine = tableLines.get(0); 220 var headers = splitTableRow(headerLine); 221 if (headers.isEmpty()) 222 return null; 223 224 // Skip separator row (|---|---|) 225 var dataLines = tableLines.stream().skip(1).filter(l -> !isSeparatorRow(l)).toList(); 226 227 // Detect table type: if 2 columns and first header is "Property"/"Key", treat as key/value table 228 var isKeyValue = headers.size() == 2 229 && (eqic(headers.get(0), "Property") || eqic(headers.get(0), "Key")) 230 && eqic(headers.get(1), "Value"); 231 232 if (isKeyValue) 233 return readKeyValueTable(headers, dataLines, eType, outer); 234 return readMultiColumnTable(headers, dataLines, eType, outer); 235 } 236 237 /** 238 * Parses a 2-column key/value Markdown table. 239 * 240 * @param headers The header names. 241 * @param dataLines The data row lines. 242 * @param eType The expected type. 243 * @param outer The outer object. 244 * @return The parsed bean, map, or other object. 245 * @throws ParseException If parsing fails. 246 */ 247 @SuppressWarnings({ 248 "java:S135" // Two continue statements needed: skip short rows and skip _type row 249 }) 250 protected Object readKeyValueTable(List<String> headers, List<String> dataLines, ClassMeta<?> eType, Object outer) throws ParseException { 251 252 // For types with ObjectSwaps or that can't be populated via BeanMap property-setting, 253 // convert the key-value table to a JSON5 object and delegate to the full JSON5 parser. 254 if (needsJson5Path(eType)) { 255 var json5 = keyValueTableToJson5(dataLines); 256 try { 257 return getJson5Parser().readWithOuter(json5, eType, outer); 258 } catch (Exception e) { 259 throw new ParseException(this, e, "Could not parse key-value table as '%s' via JSON5 '%s'.", eType, json5); 260 } 261 } 262 263 if (eType.isBean()) { 264 // First pass: look for _type to resolve concrete class for abstract/interface types 265 ClassMeta<?> actualType = eType; 266 for (var line : dataLines) { 267 var cells = splitTableRow(line); 268 if (cells.size() >= 2 && eq(cells.get(0), "_type")) { 269 var typeName = cells.get(1); 270 var registry = eType.getBeanRegistry(); 271 var resolved = registry != null ? registry.getClassMeta(typeName) : null; 272 if (resolved != null) 273 actualType = resolved; 274 break; 275 } 276 } 277 var m = newBeanMap(outer, actualType.inner()); 278 for (var line : dataLines) { 279 var cells = splitTableRow(line); 280 if (cells.size() < 2) 281 continue; 282 var key = cells.get(0); 283 284 // Skip _type row - it was used for type resolution above 285 if (eq(key, "_type")) 286 continue; 287 288 var rawVal = cells.get(1); 289 var pm = m.getPropertyMeta(key); 290 if (pm != null) { 291 setCurrentProperty(pm); 292 var pmcm = (ClassMeta<?>) pm.getBeanInfo(); 293 var val = readCellValue(rawVal, pmcm, m.getBean(false)); 294 try { 295 setName(pmcm, val, key); 296 } catch (Exception e) { 297 throw new ParseException(this, e, "Could not set @NameProperty on '%s'.", key); 298 } 299 pm.set(m, key, val); 300 setCurrentProperty(null); 301 } else { 302 onUnknownProperty(key, m, rawVal); 303 } 304 } 305 return checkRequired(m).getBean(); 306 } 307 308 if (eType.isMap()) { 309 Map map = eType.canCreateNewInstance(outer) ? (Map) eType.newInstance(outer) : newGenericMap(); 310 var keyType = eType.getKeyType() != null ? eType.getKeyType() : string(); 311 var valueType = eType.getValueType() != null ? eType.getValueType() : object(); 312 for (var line : dataLines) { 313 var cells = splitTableRow(line); 314 if (cells.size() < 2) 315 continue; 316 var rawKey = cells.get(0); 317 var key = readCellValue(rawKey, keyType, null); 318 // Pass the map being built as the outer when parsing values - non-static inner 319 // classes whose enclosing class is the map type (e.g. H1 inside H extends LinkedHashMap) 320 // need that map instance to satisfy their implicit outer-class constructor argument. 321 var val = readCellValue(cells.get(1), valueType, map); 322 try { 323 setName(valueType, val, key); 324 } catch (Exception e) { 325 throw new ParseException(this, e, "Could not set @NameProperty for map key '%s'.", key); 326 } 327 map.put(key, val); 328 } 329 return map; 330 } 331 332 if (!eType.isObject()) { 333 // Scalar/Number/primitive (or any other non-bean, non-map, non-Object) target: a key/value 334 // table has no shape that maps onto a single scalar *value*, but this used to fall through to 335 // the generic "Object or unknown" handling below regardless of eType, unconditionally building 336 // a MarshalledMap and returning that instead of a T -- throwing a ClassCastException back at 337 // the caller. Only the first data row's value cell (if any) is meaningful for a genuine scalar 338 // target, so parse and return it directly instead of wrapping the whole table in a map. 339 for (var line : dataLines) { 340 var cells = splitTableRow(line); 341 if (cells.size() >= 2) 342 return readCellValue(cells.get(1), eType, null); 343 } 344 return null; 345 } 346 347 // Object or unknown: return MarshalledMap, check for _type and cast if possible 348 var resultMap = newGenericMap(); 349 for (var line : dataLines) { 350 var cells = splitTableRow(line); 351 if (cells.size() < 2) 352 continue; 353 var key = cells.get(0); 354 var val = readCellValue(cells.get(1), object(), null); 355 resultMap.put(key, val); 356 } 357 358 // Check for _type property and cast to typed bean if found 359 if (resultMap.containsKey(getBeanTypePropertyName(eType))) { 360 return cast(resultMap, null, eType); 361 } 362 363 return resultMap; 364 } 365 366 /** 367 * Parses a multi-column Markdown table (N columns). 368 * 369 * @param headers The column header names. 370 * @param dataLines The data row lines. 371 * @param eType The expected type. 372 * @param outer The outer object. 373 * @return The parsed list of beans, maps, or other objects. 374 * @throws ParseException If parsing fails. 375 */ 376 protected Object readMultiColumnTable(List<String> headers, List<String> dataLines, ClassMeta<?> eType, Object outer) throws ParseException { 377 ClassMeta<?> elementType; 378 Collection<Object> result; 379 380 if (eType.isArray()) { 381 elementType = eType.getElementType(); 382 result = l(); 383 } else if (eType.isCollection()) { 384 elementType = eType.getElementType(); 385 result = eType.canCreateNewInstance(outer) ? (Collection<Object>) eType.newInstance() : l(); 386 } else if (eType.isObject()) { 387 elementType = object(); 388 result = l(); 389 } else { 390 // Single bean/map with multiple columns — not a standard use case, parse first row 391 if (!dataLines.isEmpty()) { 392 var cells = splitTableRow(dataLines.get(0)); 393 return readRow(headers, cells, eType, outer); 394 } 395 return null; 396 } 397 398 for (var line : dataLines) { 399 var cells = splitTableRow(line); 400 var item = readRow(headers, cells, elementType, result); 401 result.add(item); 402 } 403 404 if (eType.isArray()) 405 return toArray(eType, result); 406 407 return result; 408 } 409 410 /** 411 * Parses a single data row into the specified element type. 412 * 413 * @param headers The column header names. 414 * @param cells The cell values for this row. 415 * @param eType The element type. 416 * @param outer The outer object. 417 * @return The parsed element. 418 * @throws ParseException If parsing fails. 419 */ 420 protected Object readRow(List<String> headers, List<String> cells, ClassMeta<?> eType, Object outer) throws ParseException { 421 // Check if all cells are null - if so, return null for the entire row 422 boolean allNull = true; 423 for (var cell : cells) { 424 if (cell != null && neq(cell, nullValue) && !cell.trim().isEmpty()) { 425 allNull = false; 426 break; 427 } 428 } 429 if (allNull) { 430 return null; 431 } 432 433 // Check if there's a _type column and resolve the actual type using the bean registry 434 ClassMeta<?> actualType = eType; 435 int typeColIndex = headers.indexOf("_type"); 436 if (typeColIndex >= 0 && typeColIndex < cells.size()) { 437 var typeName = cells.get(typeColIndex); 438 if (ine(typeName)) { 439 var registry = eType != null ? eType.getBeanRegistry() : null; 440 var resolved = registry != null ? registry.getClassMeta(typeName) : null; 441 if (resolved != null) 442 actualType = resolved; 443 } 444 } 445 446 // For types with ObjectSwaps or that can't be populated via BeanMap property-setting, 447 // convert the row to a JSON5 object and delegate to the full JSON5 parser. This correctly 448 // handles ObjectSwaps, @ParentProperty, JsonMap-constructor beans, etc. 449 if (needsJson5Path(actualType)) { 450 var json5 = rowToJson5(headers, cells); 451 try { 452 return getJson5Parser().readWithOuter(json5, actualType, outer); 453 } catch (Exception e) { 454 throw new ParseException(this, e, "Could not parse table row as '%s' via JSON5 '%s'.", actualType, json5); 455 } 456 } 457 458 if (actualType == null || actualType.isObject()) { 459 var m = newGenericMap(); 460 for (var i = 0; i < headers.size(); i++) { 461 var header = headers.get(i); 462 if (eq(header, "_type")) 463 continue; 464 var val = i < cells.size() ? cells.get(i) : null; 465 m.put(header, readCellValue(val, object(), null)); 466 } 467 if (typeColIndex >= 0 && m.size() > 0) 468 return cast(m, null, eType); 469 return m; 470 } 471 if (actualType.isBean()) { 472 var m = newBeanMap(outer, actualType.inner()); 473 for (var i = 0; i < headers.size(); i++) { 474 var header = headers.get(i); 475 if (eq(header, "_type")) 476 continue; 477 var rawVal = i < cells.size() ? cells.get(i) : null; 478 var pm = m.getPropertyMeta(header); 479 if (pm != null) { 480 setCurrentProperty(pm); 481 var val = readCellValue(rawVal, (ClassMeta<?>) pm.getBeanInfo(), m.getBean(false)); 482 pm.set(m, header, val); 483 setCurrentProperty(null); 484 } else { 485 onUnknownProperty(header, m, rawVal); 486 } 487 } 488 return checkRequired(m).getBean(); 489 } 490 if (actualType.isMap()) { 491 Map map = actualType.canCreateNewInstance(outer) ? (Map) actualType.newInstance(outer) : newGenericMap(); 492 var keyType = actualType.getKeyType() != null ? actualType.getKeyType() : string(); 493 var valueType = actualType.getValueType() != null ? actualType.getValueType() : object(); 494 for (var i = 0; i < headers.size(); i++) { 495 var header = headers.get(i); 496 if (eq(header, "_type")) 497 continue; 498 var key = convertAttrToType(map, header, keyType); 499 var rawVal = i < cells.size() ? cells.get(i) : null; 500 // Pass the map being built as the outer when parsing values - mirrors the 501 // key-value-table branch so non-static inner classes whose enclosing class is the 502 // map type can be instantiated correctly. 503 var val = readCellValue(rawVal, valueType, map); 504 map.put(key, val); 505 } 506 return map; 507 } 508 // Simple type: use first cell 509 return readCellValue(cells.isEmpty() ? null : cells.get(0), actualType, null); 510 } 511 512 /** 513 * Parses a Markdown bulleted list into a collection or array. 514 * 515 * @param lines All input lines. 516 * @param eType The expected type. 517 * @param outer The outer object. 518 * @return The parsed collection, array, or single element. 519 * @throws ParseException If parsing fails. 520 */ 521 protected Object readBulletList(List<String> lines, ClassMeta<?> eType, Object outer) throws ParseException { 522 var items = new ArrayList<String>(); 523 for (var line : lines) { 524 var trimmed = line.trim(); 525 if (trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.startsWith("+ ")) 526 items.add(trimmed.substring(2)); 527 else if (eqa(trimmed, "-", "*", "+")) 528 items.add(""); 529 } 530 531 ClassMeta<?> elementType; 532 Collection<Object> result; 533 534 if (eType.isArray()) { 535 elementType = eType.getElementType(); 536 result = l(); 537 } else if (eType.isCollection()) { 538 elementType = eType.getElementType(); 539 result = eType.canCreateNewInstance(outer) ? (Collection<Object>) eType.newInstance() : l(); 540 } else if (!eType.isMap() && !eType.isBean()) { 541 // Scalar/Number/primitive target: a bullet list has no shape that maps onto a single scalar 542 // *value*, but the code below always wrapped every parsed item into a List and returned that -- 543 // even when the caller asked for a bare T -- which threw a ClassCastException back at the 544 // top-level read() cast. Only the first item (if any) is meaningful for a genuine scalar 545 // target, so parse and return it directly instead of wrapping it in a list. 546 if (items.isEmpty()) 547 return null; 548 ClassMeta<?> scalarType = eType.isObject() ? object() : eType; 549 return readCellValue(items.get(0), scalarType, null); 550 } else { 551 elementType = eType.isObject() ? object() : eType; 552 result = l(); 553 } 554 555 for (var item : items) 556 result.add(readCellValue(item, elementType, null)); 557 558 if (eType.isArray()) 559 return toArray(eType, result); 560 561 return result; 562 } 563 564 /** 565 * Returns a Json5Parser that shares this session's bean context (swaps, implClasses, dictionary). 566 * 567 * <p> 568 * Memoized via {@link Memoizer} — first call constructs the parser; subsequent calls return the cached instance. 569 */ 570 private JsonParser getJson5Parser() { 571 return json5Parser.get(); 572 } 573 574 /** 575 * Converts a raw Markdown table cell string to the target type. 576 * 577 * <p> 578 * If the cell matches the configured null value, returns {@code null}. 579 * If the cell is backtick-wrapped JSON5, parses via {@link Json5Parser}. 580 * Otherwise, attempts numeric/boolean auto-detection when the target type is {@code Object}. 581 * 582 * @param <T> The target type. 583 * @param val The raw cell string (already unescaped). 584 * @param eType The target type. 585 * @param outer The outer object (parent bean) for @ParentProperty support. 586 * @return The parsed value. 587 * @throws ParseException If parsing fails. 588 */ 589 protected <T> T readCellValue(String val, ClassMeta<T> eType, Object outer) throws ParseException { 590 if (val == null) 591 return null; 592 val = val.trim(); 593 if (eq(val, nullValue)) 594 return null; 595 if (isTrimStrings()) 596 val = val.trim(); 597 // For String types, preserve empty cells as empty strings; for other types, treat as null 598 if (val.isEmpty()) 599 return eType.isString() ? (T) "" : null; 600 601 // Inline JSON5 wrapped in backticks — use context-aware parser with outer for full support 602 // (handles ObjectSwaps, @ParentProperty, JsonMap constructors, etc.) 603 if (val.startsWith("`") && val.endsWith("`") && val.length() > 1) { 604 var inner = val.substring(1, val.length() - 1); 605 try { 606 return getJson5Parser().readWithOuter(inner, eType, outer); 607 } catch (Exception e) { 608 throw new ParseException(this, e, "Could not parse inline JSON5 '%s' to type '%s'.", inner, eType); 609 } 610 } 611 612 // Unescape table cell escaping 613 val = unescapeCell(val); 614 615 if (eType.isObject()) { 616 // Auto-detect type 617 if (eq(val, "true")) return (T) Boolean.TRUE; 618 if (eq(val, "false")) return (T) Boolean.FALSE; 619 try { return (T) Integer.valueOf(val); } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* not int, try next */ } 620 try { return (T) Long.valueOf(val); } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* not long, try next */ } 621 try { return (T) Double.valueOf(val); } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* not double, treat as string */ } 622 return (T) val; 623 } 624 625 // Date / time / duration cluster: route through the format-aware parsers so the configured 626 // MarshallingContext.get<Format>() hint reaches the wire-form coercion. Without this the 627 // fall-through to convertToType(val, eType) below would route a bare numeric cell through a 628 // generic Number → T coercion (e.g. Duration.ofMillis(long)) that drops the format hint. 629 if (eType.isDate()) 630 return readDate(val, eType); 631 if (eType.isCalendar()) 632 return readCalendar(val, eType); 633 if (eType.isTemporal()) 634 return readTemporal(val, eType); 635 if (eType.isDuration()) 636 return (T) readDuration(val); 637 if (eType.isPeriod()) 638 return (T) readPeriod(val); 639 640 try { 641 return convertToType(val, eType); 642 } catch (@SuppressWarnings("unused") Exception e) { 643 throw new ParseException(this, "Could not convert Markdown cell value '%s' to type '%s'.", val, eType); 644 } 645 } 646 647 /** 648 * Returns true if the given ClassMeta requires the full JSON5 parsing machinery to be correctly 649 * instantiated — i.e., it has an ObjectSwap, cannot be created as a standard bean, or cannot be 650 * populated via direct property-setting through a BeanMap. 651 * 652 * <p> 653 * When this returns true, table rows and key-value tables should be converted to a JSON5 string 654 * and parsed via {@link Json5Parser} (using 655 * {@link Parser#readWithOuter}) to correctly handle swaps, 656 * {@link ParentProperty @ParentProperty}, and non-standard constructors. 657 */ 658 private boolean needsJson5Path(ClassMeta<?> type) { 659 if (type == null || type.isObject() || type.isMap() || type.isPrimitive() || type.isNumber() || type.isString()) 660 return false; 661 // If the type has an ObjectSwap, it cannot be parsed via BeanMap property-setting 662 if (type.getSwap(this) != null) 663 return true; 664 // If not a bean (no public properties), can't use BeanMap approach 665 return !type.isBean(); 666 } 667 668 /** 669 * Converts a single Markdown table cell value to its JSON5 literal representation. 670 * 671 * <p> 672 * Inline JSON5 (backtick-wrapped) is unwrapped directly. Booleans and numbers are written bare. 673 * Everything else is single-quoted as a JSON5 string. 674 */ 675 private String cellToJson5(String cell) { 676 if (cell == null || eq(cell, nullValue)) 677 return "null"; 678 cell = cell.trim(); 679 if (cell.isEmpty()) 680 return "''"; 681 // Inline JSON5 wrapped in backticks — unwrap and embed directly 682 if (cell.startsWith("`") && cell.endsWith("`") && cell.length() > 1) 683 return cell.substring(1, cell.length() - 1); 684 // Unescape Markdown table escaping first 685 cell = unescapeCell(cell); 686 // Booleans and numbers don't need quoting 687 if (eqa(cell, "true", "false")) 688 return cell; 689 try { Integer.parseInt(cell); return cell; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* not int, try next */ } 690 try { Long.parseLong(cell); return cell; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* not long, try next */ } 691 try { Double.parseDouble(cell); return cell; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* not double, quote as string */ } 692 // String: single-quote with escaping for JSON5 693 return "'" + cell.replace("\\", "\\\\").replace("'", "\\'") + "'"; 694 } 695 696 /** 697 * Converts a table row (headers + cells) into a JSON5 object string, suitable for parsing via 698 * {@link Json5Parser}. 699 * 700 * <p> 701 * Example: headers=["f1","f2"], cells=["a","2"] → <code>{f1:'a',f2:2}</code> 702 * 703 * <p> 704 * The {@code _type} column, if present, is included as-is (as a quoted string) so that the JSON5 705 * parser can perform type resolution via the bean dictionary. 706 */ 707 private String rowToJson5(List<String> headers, List<String> cells) { 708 var sb = new StringBuilder("{"); 709 var first = true; 710 for (var i = 0; i < headers.size(); i++) { 711 var header = headers.get(i); 712 var cell = i < cells.size() ? cells.get(i) : null; 713 if (!first) sb.append(","); 714 first = false; 715 sb.append(header).append(":"); 716 if (eq(header, "_type")) { 717 // Keep _type as a quoted string so the JSON5 parser can resolve it 718 sb.append(cell == null ? "null" : "'" + cell + "'"); 719 } else { 720 sb.append(cellToJson5(cell)); 721 } 722 } 723 sb.append("}"); 724 return sb.toString(); 725 } 726 727 /** 728 * Converts a key-value table (list of [key, value] row lines) into a JSON5 object string. 729 */ 730 private String keyValueTableToJson5(List<String> dataLines) { 731 var sb = new StringBuilder("{"); 732 var first = true; 733 for (var line : dataLines) { 734 var cells = splitTableRow(line); 735 if (cells.size() < 2) 736 continue; 737 var key = cells.get(0); 738 var val = cells.get(1); 739 if (!first) sb.append(","); 740 first = false; 741 sb.append(key).append(":"); 742 if (eq(key, "_type")) { 743 sb.append(val == null ? "null" : "'" + val + "'"); 744 } else { 745 sb.append(cellToJson5(val)); 746 } 747 } 748 sb.append("}"); 749 return sb.toString(); 750 } 751 752 /** 753 * Splits a Markdown table row into its cell values, stripping leading/trailing whitespace and pipes. 754 * 755 * @param line The raw table row line. 756 * @return The list of trimmed cell values. 757 */ 758 protected List<String> splitTableRow(String line) { 759 var trimmed = line.trim(); 760 if (trimmed.startsWith("|")) 761 trimmed = trimmed.substring(1); 762 if (trimmed.endsWith("|") && (trimmed.length() < 2 || trimmed.charAt(trimmed.length() - 2) != '\\')) 763 trimmed = trimmed.substring(0, trimmed.length() - 1); 764 765 // Split on unescaped pipe characters 766 var result = new ArrayList<String>(); 767 var sb = new StringBuilder(); 768 var i = 0; 769 while (i < trimmed.length()) { 770 var c = trimmed.charAt(i); 771 if (c == '\\' && i + 1 < trimmed.length()) { 772 var next = trimmed.charAt(i + 1); 773 if (next == '|' || next == '\\') { 774 sb.append('\\'); 775 sb.append(next); 776 i += 2; 777 continue; 778 } 779 } 780 if (c == '|') { 781 result.add(sb.toString().trim()); 782 sb.setLength(0); 783 } else { 784 sb.append(c); 785 } 786 i++; 787 } 788 if (!sb.isEmpty() || !result.isEmpty()) 789 result.add(sb.toString().trim()); 790 return result; 791 } 792 793 /** 794 * Returns whether a table row is a separator row (e.g. {@code |---|---|}). 795 * 796 * @param line The table row line. 797 * @return Whether it is a separator row. 798 */ 799 protected boolean isSeparatorRow(String line) { 800 var trimmed = line.trim(); 801 if (!trimmed.startsWith("|")) 802 return false; 803 // Equivalent of trimmed.replaceAll("[|\\-: ]", "").isEmpty(): true iff every char is one of | - : space. 804 for (var i = 0; i < trimmed.length(); i++) { 805 var c = trimmed.charAt(i); 806 if (c != '|' && c != '-' && c != ':' && c != ' ') 807 return false; 808 } 809 return true; 810 } 811 812 /** 813 * Reads all lines from the given reader. 814 * 815 * @param r The reader. 816 * <br>Must not be <jk>null</jk>. 817 * @return All lines. 818 * @throws IOException Thrown by underlying stream. 819 */ 820 protected List<String> readAllLines(BufferedReader r) throws IOException { 821 var lines = new ArrayList<String>(); 822 String line; 823 while ((line = r.readLine()) != null) 824 lines.add(line); 825 return lines; 826 } 827 828 /** 829 * Unescapes Markdown table cell escape sequences. 830 * 831 * @param s The escaped cell value. 832 * <br>Can be <jk>null</jk> (returns <jk>null</jk>). 833 * @return The unescaped value. Returns <jk>null</jk> if the input was <jk>null</jk>. 834 */ 835 protected String unescapeCell(String s) { 836 if (s == null || !s.contains("\\")) 837 return s; 838 var sb = new StringBuilder(s.length()); 839 var i = 0; 840 while (i < s.length()) { 841 var c = s.charAt(i); 842 if (c == '\\' && i + 1 < s.length()) { 843 var next = s.charAt(i + 1); 844 if (next == '|' || next == '\\') { 845 sb.append(next); 846 i += 2; 847 continue; 848 } 849 } 850 sb.append(c); 851 i++; 852 } 853 return sb.toString(); 854 } 855}