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.utils.Shorts.*;
020
021import java.io.*;
022import java.lang.reflect.*;
023import java.time.*;
024import java.time.temporal.*;
025import java.util.*;
026
027import org.apache.juneau.commons.bean.*;
028import org.apache.juneau.marshall.*;
029import org.apache.juneau.marshall.json.*;
030import org.apache.juneau.marshall.json5.*;
031import org.apache.juneau.marshall.serializer.*;
032import org.apache.juneau.marshall.stream.*;
033import org.apache.juneau.marshall.swap.spi.*;
034
035/**
036 * Session object for {@link MarkdownSerializer}.
037 *
038 * <h5 class='section'>Notes:</h5><ul>
039 *    <li class='warn'>This class is not thread safe and is typically discarded after one use.
040 * </ul>
041 *
042 */
043@SuppressWarnings({
044   "java:S110", // Inheritance depth acceptable for serializer session hierarchy
045   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
046   "java:S3740", // Raw ClassMeta/ObjectSwap types used throughout serializer session; parameterization requires extensive generic changes
047   "java:S3776", // Cognitive complexity acceptable for doWrite
048   "java:S6541", // Brain method acceptable for doWrite / writeAnything
049   "rawtypes", // Raw ClassMeta locals (cm, itemCm, swappedCm) in writeAnything(), writeCollection() and friends are passed to the ObjectSwap API, which is typed on the raw class
050   "resource" // MarkdownWriter/Writer lifecycle managed by SerializerPipe
051})
052public class MarkdownSerializerSession extends WriterSerializerSession implements RecordWritable {
053
054   final String nullValue;
055   final boolean showHeaders;
056
057   /**
058    * Builder class.
059    */
060   @SuppressWarnings({
061      "java:S119" // 'SELF' (CRTP self-type) is intentional and clearer than a single-letter name.
062   })
063   public abstract static class Builder<SELF extends Builder<SELF>> extends WriterSerializerSession.Builder<SELF> {
064
065      String nullValue;
066      boolean showHeaders;
067
068      /**
069       * Constructor.
070       *
071       * @param ctx The context creating this session.
072       *    <br>Cannot be <jk>null</jk>.
073       */
074      protected Builder(MarkdownSerializer ctx) {
075         super(reqnn("ctx", ctx));
076         nullValue = ctx.getNullValue();
077         showHeaders = ctx.isShowHeaders();
078      }
079
080      @Override
081      public MarkdownSerializerSession build() {
082         return new MarkdownSerializerSession(this);
083      }
084
085   }
086
087   /**
088    * Concrete default builder leaf for the non-subclassed {@code create()} path (CRTP terminal).
089    */
090   public static final class DefaultBuilder extends Builder<DefaultBuilder> {
091
092      DefaultBuilder(MarkdownSerializer ctx) {
093         super(ctx);
094      }
095   }
096
097   /**
098    * Creates a new builder for this object.
099    *
100    * @param ctx The context creating this session.
101    *    <br>Cannot be <jk>null</jk>.
102    * @return A new builder.
103    */
104   @SuppressWarnings({
105      "java:S1452" // Builder<?> wildcard return intentional; callers use it to construct session instances polymorphically
106   })
107   public static Builder<?> create(MarkdownSerializer ctx) {
108      return new DefaultBuilder(reqnn("ctx", ctx));
109   }
110
111   /**
112    * Constructor.
113    *
114    * @param builder The builder for this object.
115    */
116   protected MarkdownSerializerSession(Builder<?> builder) {
117      super(builder);
118      nullValue = or(builder.nullValue, "*null*");
119      showHeaders = builder.showHeaders;
120   }
121
122   @Override /* RecordWritable */
123   public RecordWriter writeRecords(Object output) throws IOException {
124      return RecordAdapter.writer(this, output);
125   }
126
127   @Override /* RecordWritable */
128   public boolean isRecordStreaming() {
129      return false;
130   }
131
132   @Override /* Overridden from SerializerSession */
133   protected void doWrite(SerializerPipe pipe, Object o) throws IOException, SerializeException {
134      try (var w = getMarkdownWriter(pipe)) {
135         writeAnything(w, o, getExpectedRootType(o), null);
136      }
137   }
138
139   /**
140    * Main serialization dispatch method.
141    *
142    * @param w The writer to write to.
143    * @param o The object to serialize.
144    * @param eType The expected type of the object.
145    * @param pMeta The bean property meta (may be null).
146    * @throws IOException Thrown by underlying stream.
147    * @throws SerializeException If serialization fails.
148    */
149   protected void writeAnything(MarkdownWriter w, Object o, ClassMeta<?> eType, BeanPropertyMeta pMeta) throws IOException, SerializeException {
150      if (o == null) {
151         w.text(nullValue);
152         return;
153      }
154
155      ClassMeta cm = push2("root", o, eType);
156      if (cm == null) {
157         // Recursion detected
158         pop();
159         w.text(nullValue);
160         return;
161      }
162
163      ObjectSwap swap = cm.getSwap(this);
164      if (swap != null) {
165         o = swap(swap, o);
166         cm = swap.getSwapClassMeta(this);
167      }
168
169      if (cm.isOptional()) {
170         var opt = (Optional<?>) o;
171         if (opt.isEmpty()) {
172            pop();
173            w.text(nullValue);
174         } else {
175            writeAnything(w, opt.get(), eType, pMeta);
176            pop();
177         }
178         return;
179      }
180
181      if (cm.isBean()) {
182         writeBeanMap(w, toBeanMap(o), eType, cm);
183      } else if (cm.isMap()) {
184         writeMap(w, (Map<?,?>) o, cm);
185      } else if (cm.isCollectionOrArray()) {
186         var l = cm.isArray() ? toObjectList(o) : (Collection<?>) o;
187         writeCollection(w, l, eType, cm);
188      } else if (cm.isStreamable()) {
189         writeCollection(w, toListFromStreamable(o, cm), eType, cm);
190      } else if (cm.isDate()) {
191         w.text(writeDate((Date)o, cm));
192      } else if (cm.isCalendar()) {
193         w.text(writeCalendar(o, cm));
194      } else if (cm.isTemporal()) {
195         w.text(writeTemporal((TemporalAccessor)o, cm));
196      } else if (cm.isDuration()) {
197         w.text(writeDuration((Duration)o));
198      } else if (cm.isPeriod()) {
199         w.text(writePeriod((Period)o));
200      } else {
201         var s = toString(o);
202         // Only use JSON5 wrapping for actual string/enum types where auto-detection could misinterpret
203         // the value; non-bean objects (e.g. those with a valueOf(String) factory) use plain text so
204         // that the parser can use convertToType() on the raw string.
205         if ((cm.isCharSequence() || cm.isEnum()) && isAmbiguousString(s, nullValue))
206            w.text("`'" + escapeJson5String(s) + "'`");
207         else
208            w.text(MarkdownWriter.escapeText(s));
209      }
210      
211      pop();
212   }
213
214   /**
215    * Serializes a bean as a 2-column key/value Markdown table.
216    *
217    * @param w The writer.
218    * @param bm The bean map.
219    * @param eType The expected type (for type name calculation).
220    * @param aType The actual type (for type name calculation).
221    * @throws IOException Thrown by underlying stream.
222    * @throws SerializeException If serialization fails.
223    */
224   protected void writeBeanMap(MarkdownWriter w, BeanMap<?> bm, ClassMeta<?> eType, ClassMeta<?> aType) throws IOException, SerializeException {
225      if (showHeaders) {
226         w.tableHeader("Property", "Value");
227         w.tableSeparator(2);
228      }
229      
230      // Add _type row if needed for polymorphic type identification
231      // This handles both addBeanTypes (eType != aType) and addRootType (root object with dict name)
232      String typeName = getBeanTypeName(this, eType, aType, null);
233      if (typeName != null) {
234         w.tableRow("_type", typeName);
235      }
236      
237      for (var entry : bm.entrySet()) {
238         if (entry instanceof BeanMapEntry entry2 && !entry2.getMeta().canRead())
239            continue;
240         w.tableRow(MarkdownWriter.escapeCell(entry.getKey()), writeInlineValue(entry.getValue()));
241      }
242   }
243
244   /**
245    * Serializes a Map as a 2-column key/value Markdown table.
246    *
247    * @param w The writer.
248    * @param m The map.
249    * @param cm The class meta.
250    * @throws IOException Thrown by underlying stream.
251    * @throws SerializeException If serialization fails.
252    */
253   protected void writeMap(MarkdownWriter w, Map<?,?> m, ClassMeta<?> cm) throws IOException, SerializeException {
254      if (showHeaders) {
255         w.tableHeader("Key", "Value");
256         w.tableSeparator(2);
257      }
258      for (var entry : m.entrySet()) {
259         String rawKey = entry.getKey() == null ? null : toString(entry.getKey());
260         String keyStr;
261         if (rawKey == null)
262            keyStr = nullValue;
263         else if (isAmbiguousString(rawKey, nullValue))
264            keyStr = "`'" + escapeJson5String(rawKey) + "'`";
265         else
266            keyStr = MarkdownWriter.escapeCell(rawKey);
267         w.tableRow(keyStr, writeInlineValue(entry.getValue()));
268      }
269   }
270
271   /**
272    * Serializes a collection as either a multi-column table (uniform beans/maps) or a bulleted list.
273    *
274    * @param w The writer.
275    * @param c The collection.
276    * @param eType The expected type.
277    * @param cm The class meta.
278    * @throws IOException Thrown by underlying stream.
279    * @throws SerializeException If serialization fails.
280    */
281   protected void writeCollection(MarkdownWriter w, Collection<?> c, ClassMeta<?> eType, ClassMeta<?> cm) throws IOException, SerializeException {
282      if (c.isEmpty())
283         return;
284
285      var headers = getTableHeaders(c);
286      if (headers != null) {
287         // Uniform collection of beans or maps → multi-column table
288         
289         // Determine if we need a _type column
290         boolean needsTypeColumn = isAddBeanTypes();
291         var elementType = eType != null && eType.isCollectionOrArray() ? eType.getElementType() : cm.getElementType();
292
293         // Add _type column to headers if needed
294         String[] finalHeaders = headers;
295         if (needsTypeColumn) {
296            finalHeaders = new String[headers.length + 1];
297            finalHeaders[0] = "_type";
298            System.arraycopy(headers, 0, finalHeaders, 1, headers.length);
299         }
300
301         if (showHeaders && finalHeaders.length > 0) {
302            w.tableHeader(finalHeaders);
303            w.tableSeparator(finalHeaders.length);
304         }
305
306         for (var item : c) {
307            if (item == null) {
308               var row = new String[finalHeaders.length];
309               Arrays.fill(row, nullValue);
310               w.tableRow(row);
311               continue;
312            }
313            ClassMeta itemCm = getClassMetaForObject(item);
314            ObjectSwap itemSwap = itemCm.getSwap(this);
315            var swapped = itemSwap != null ? swap(itemSwap, item) : item;
316            ClassMeta swappedCm = itemSwap != null ? itemSwap.getSwapClassMeta(this) : itemCm;
317
318            var row = new String[finalHeaders.length];
319            int colOffset = needsTypeColumn ? 1 : 0;
320
321            // Add type name if needed
322            // For root-level collections with addRootType, treat each element as root
323            if (needsTypeColumn) {
324               String typeName;
325               if (isRoot() && isAddRootType()) {
326                  // Root-level collection: add type name if the bean has a dictionary name
327                  typeName = swappedCm.getBeanDictionaryName();
328               } else {
329                  // Nested collection: use standard logic
330                  typeName = getBeanTypeName(this, elementType, swappedCm, null);
331               }
332               row[0] = or(typeName, "");
333            }
334
335            if (swappedCm.isBean()) {
336               var bm = toBeanMap(swapped);
337               for (var i = 0; i < headers.length; i++) {
338                  var val = bm.get(headers[i]);
339                  row[i + colOffset] = writeInlineValue(val);
340               }
341            } else if (swappedCm.isMap()) {
342               var map = (Map<?,?>) swapped;
343               for (var i = 0; i < headers.length; i++) {
344                  var val = map.get(headers[i]);
345                  row[i + colOffset] = writeInlineValue(val);
346               }
347            }
348            w.tableRow(row);
349         }
350      } else {
351         // Mixed or simple values → bulleted list
352         for (var item : c)
353            w.bulletItem(0, writeInlineValue(item));
354      }
355   }
356
357   /**
358    * Returns true if the string could be misinterpreted during parsing when the target type is Object,
359    * or if the string contains characters that would be lost or corrupted in Markdown table cells.
360    *
361    * <p>Strings that are empty, equal to nullValue, look like booleans or numbers, or contain control
362    * characters (< 32 or = 127) are considered ambiguous and should be wrapped in JSON5 backtick syntax.
363    */
364   private static boolean isAmbiguousString(String s, String nullValue) {
365      if (s.isEmpty()) return true;
366      if (s.equals(nullValue)) return true;
367      if (eqa(s, "true", "false")) return true;
368      for (int i = 0; i < s.length(); i++) {
369         char c = s.charAt(i);
370         if (c < 32 || c == 127) return true;
371      }
372      try { Integer.parseInt(s); return true; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* Not a number; fall through */ }
373      try { Long.parseLong(s); return true; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* Not a number; fall through */ }
374      try { Double.parseDouble(s); return true; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* Not a number; fall through */ }
375      return false;
376   }
377
378   /**
379    * Escapes a string for use within a JSON5 single-quoted string literal.
380    */
381   private static String escapeJson5String(String s) {
382      if (s == null) return "";
383      var sb = new StringBuilder(s.length() + 4);
384      for (var i = 0; i < s.length(); i++) {
385         var c = s.charAt(i);
386         switch (c) {
387            case '\'' -> sb.append("\\'");
388            case '\\' -> sb.append("\\\\");
389            case '\n' -> sb.append("\\n");
390            case '\r' -> sb.append("\\r");
391            case '\t' -> sb.append("\\t");
392            default -> sb.append(c);
393         }
394      }
395      return sb.toString();
396   }
397
398   /**
399    * Converts an array (including primitive arrays) to a List of boxed objects.
400    */
401   private static List<Object> toObjectList(Object array) {
402      int len = Array.getLength(array);
403      var list = new ArrayList<>(len);
404      for (int i = 0; i < len; i++)
405         list.add(Array.get(array, i));
406      return list;
407   }
408
409   /**
410    * Returns a Json5Serializer that shares this session's bean context (swaps, implClasses, dictionary).
411    * Creates a new serializer each time to ensure settings are current.
412    */
413   private JsonSerializer getJson5Serializer() {
414      var b = Json5Serializer.create().marshallingContext((MarshallingContext) getContext());
415      if (isAddBeanTypes())
416         b = b.addBeanTypes();
417      if (isAddRootType())
418         b = b.addRootType();
419      if (isTrimStrings())
420         b = b.trimStrings();
421      return b.build();
422   }
423
424   /**
425    * Serializes a value for placement in a Markdown table cell.
426    *
427    * <p>
428    * Simple values (strings, numbers, booleans, enums, dates) are escaped and returned inline.
429    * Complex values (beans, maps, collections) are serialized as JSON5 in backticks.
430    *
431    * @param o The value to serialize.
432    * @return The cell string value.
433    * @throws SerializeException If serialization fails.
434    */
435   protected String writeInlineValue(Object o) throws SerializeException {
436      if (o == null)
437         return nullValue;
438
439      ClassMeta cm = getClassMetaForObject(o);
440      ObjectSwap swap = cm.getSwap(this);
441      if (swap != null) {
442         o = swap(swap, o);
443         cm = swap.getSwapClassMeta(this);
444      }
445
446      if (cm.isCharSequence() || cm.isEnum()) {
447         var s = toString(o);
448         if (isAmbiguousString(s, nullValue))
449            return "`'" + escapeJson5String(s) + "'`";
450         return MarkdownWriter.escapeCell(s);
451      }
452
453      if (cm.isNumber() || cm.isBoolean())
454         return o.toString();
455
456      if (cm.isDate())
457         return MarkdownWriter.escapeCell(writeDate((Date)o, cm));
458
459      if (cm.isCalendar())
460         return MarkdownWriter.escapeCell(writeCalendar(o, cm));
461
462      if (cm.isTemporal())
463         return MarkdownWriter.escapeCell(writeTemporal((TemporalAccessor)o, cm));
464
465      if (cm.isDuration())
466         return MarkdownWriter.escapeCell(writeDuration((Duration)o));
467
468      if (cm.isPeriod())
469         return MarkdownWriter.escapeCell(writePeriod((Period)o));
470
471      // Complex value: serialize as JSON5 in backticks — use context-aware serializer for swaps
472      try {
473         var json5 = getJson5Serializer().write(o);
474         return "`" + json5 + "`";
475      } catch (@SuppressWarnings("unused") SerializeException e) {
476         return MarkdownWriter.escapeCell(o.toString());
477      }
478   }
479
480   /**
481    * Determines if a collection can be rendered as a multi-column table.
482    *
483    * <p>
484    * Returns the column header names if the collection is a uniform set of beans or maps,
485    * or {@code null} if it should be rendered as a bulleted list.
486    *
487    * @param c The collection to inspect.
488    *    <br>Must not be <jk>null</jk>.
489    * @return The column header names, or {@code null} for list rendering.
490    * @throws SerializeException If inspection fails.
491    */
492   @SuppressWarnings({
493      "java:S1168" // null signals list rendering; empty array would incorrectly trigger table mode
494   })
495   protected String[] getTableHeaders(Collection<?> c) throws SerializeException {
496      if (c.isEmpty())
497         return null;
498
499      // Find first non-null element
500      Object first = null;
501      for (var item : c) {
502         if (item != null) {
503            first = item;
504            break;
505         }
506      }
507      if (first == null)
508         return null;
509
510      ClassMeta cm = getClassMetaForObject(first);
511      ObjectSwap swap = cm.getSwap(this);
512      if (swap != null) {
513         try {
514            first = swap(swap, first);
515            cm = swap.getSwapClassMeta(this);
516         } catch (@SuppressWarnings("unused") SerializeException e) {
517            return null;
518         }
519      }
520
521      if (!cm.isMapOrBean())
522         return null;
523
524      if (cm.isBean()) {
525         var bm = toBeanMap(first);
526         // Skip write-only properties: they are never serialized, so they get no column.
527         return bm.keySet().stream().filter(k -> bm.getProperty(k).getMeta().canRead()).toArray(String[]::new);
528      }
529
530      // Non-bean map: collect keys from first entry
531      if (first instanceof Map<?,?> map2) {
532         return map2.keySet().stream().map(Object::toString).toArray(String[]::new);
533      }
534
535      return null;
536   }
537
538   /**
539    * Gets or creates a {@link MarkdownWriter} from the given pipe.
540    *
541    * @param out The serializer pipe.
542    *    <br>Must not be <jk>null</jk>.
543    * @return The writer.
544    */
545   protected MarkdownWriter getMarkdownWriter(SerializerPipe out) {
546      var output = out.getRawOutput();
547      if (output instanceof MarkdownWriter output2)
548         return output2;
549      var w = new MarkdownWriter(out.getWriter(), isUseWhitespace(), getMaxIndent(), isTrimStrings(), getUriResolver());
550      out.setWriter(w);
551      return w;
552   }
553}