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}