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.util.*;
023
024import org.apache.juneau.marshall.*;
025import org.apache.juneau.marshall.parser.*;
026
027/**
028 * Session object for {@link MarkdownDocParser}.
029 *
030 * <p>
031 * Parses Markdown documents (headings + tables) produced by {@link MarkdownDocSerializer}
032 * into Java objects by mapping heading sections to nested bean properties.
033 *
034 * <h5 class='section'>Notes:</h5><ul>
035 *    <li class='warn'>This class is not thread safe and is typically discarded after one use.
036 * </ul>
037 *
038 */
039@SuppressWarnings({
040   "java:S110", // Inheritance depth acceptable for parser session hierarchy
041   "java:S115", // Constants use UPPER_snakeCase convention
042   "java:S3776", // Cognitive complexity acceptable for doRead / readSections
043   "java:S6541", // Brain method acceptable for readSections
044   "rawtypes", // readMapFromDoc() builds its result as a raw Map (sType.newInstance / newGenericMap) and populates it reflectively
045   "unchecked" // Casts of the ClassMeta<?> property/value types to ClassMeta<Object>/ClassMeta<T> and of the result to T are guaranteed by the bean property or requested type
046})
047public class MarkdownDocParserSession extends MarkdownParserSession {
048
049   private static final String CONST_Property = "Property";
050   private static final String CONST_Value = "Value";
051
052   private final int headingLevel;
053
054   /**
055    * Builder class.
056    */
057   public static class Builder extends MarkdownParserSession.Builder<Builder> {
058
059      int headingLevel;
060
061      /**
062       * Constructor.
063       *
064       * @param ctx The context creating this session.
065       */
066      protected Builder(MarkdownDocParser ctx) {
067         super(ctx);
068         headingLevel = ctx.headingLevel;
069      }
070
071      @Override
072      public MarkdownDocParserSession build() {
073         return new MarkdownDocParserSession(this);
074      }
075
076   }
077
078   /**
079    * Creates a new builder for this object.
080    *
081    * @param ctx The context creating this session.
082    *    <br>Cannot be <jk>null</jk>.
083    * @return A new builder.
084    */
085   public static Builder create(MarkdownDocParser ctx) {
086      return new Builder(ctx);
087   }
088
089   /**
090    * Constructor.
091    *
092    * @param builder The builder for this object.
093    */
094   protected MarkdownDocParserSession(Builder builder) {
095      super(builder);
096      headingLevel = builder.headingLevel;
097   }
098
099   @Override /* Overridden from MarkdownParserSession */
100   protected <T> T doRead(ParserPipe pipe, ClassMeta<T> type) throws IOException, ParseException {
101      try (var r = new BufferedReader(pipe.getReader())) {
102         var lines = readAllLines(r);
103         return readDocAnything(lines, type, getOuter(), headingLevel);
104      }
105   }
106
107   /**
108    * Main document-mode parse dispatch.
109    *
110    * @param <T> The target type.
111    * @param lines All input lines.
112    * @param eType The expected type.
113    * @param outer The outer object.
114    * @param level The expected heading level for the top-level section.
115    * @return The parsed object.
116    * @throws IOException Thrown by underlying stream.
117    * @throws ParseException If parsing fails.
118    */
119   protected <T> T readDocAnything(List<String> lines, ClassMeta<T> eType, Object outer, int level) throws IOException, ParseException {
120      if (eType == null)
121         eType = (ClassMeta<T>) object();
122
123      var swap = (org.apache.juneau.marshall.swap.spi.ObjectSwap<T,Object>) eType.getSwap(this);
124      var builder = (org.apache.juneau.marshall.swap.spi.BuilderSwap<T,Object>) eType.getBuilderSwap(this);
125      ClassMeta<?> sType;
126      if (builder != null)
127         sType = builder.getBuilderClassMeta(this);
128      else if (swap != null)
129         sType = swap.getSwapClassMeta(this);
130      else
131         sType = eType;
132
133      Object o = null;
134
135      if (sType.isBean()) {
136         o = readBeanFromDoc(lines, sType, outer, level);
137      } else if (sType.isMap()) {
138         o = readMapFromDoc(lines, sType, outer, level);
139      } else {
140         // For collections/arrays and simple types, fall back to fragment parsing
141         o = readAnything(lines, (ClassMeta<T>) sType, outer, null);
142      }
143
144      if (builder != null && o != null)
145         o = builder.build(this, o, eType);
146
147      if (swap != null && o != null)
148         o = unswap(swap, o, eType);
149
150      return (T) o;
151   }
152
153   /**
154    * Parses a bean from Markdown document structure (headings + tables).
155    *
156    * <p>
157    * The top-level heading (title) is ignored. Inline key/value tables at the current heading level
158    * populate simple properties. Sub-headings at {@code level+1} map to nested bean properties.
159    *
160    * @param lines All document lines.
161    * @param sType The bean type.
162    * @param outer The outer object.
163    * @param level The current heading level.
164    * @return The parsed bean.
165    * @throws ParseException If parsing fails.
166    */
167   @SuppressWarnings({
168      "java:S135" // Multiple continue statements in loop; refactoring would reduce clarity
169   })
170   protected Object readBeanFromDoc(List<String> lines, ClassMeta<?> sType, Object outer, int level) throws IOException, ParseException {
171      var m = newBeanMap(outer, sType.inner());
172
173      // Segment lines into: top-level content and sub-sections
174      // Sections are delineated by headings at level+1
175      var sections = segmentBySections(lines, level + 1);
176
177      // The "root" section (before any sub-heading) holds the key/value table
178      var rootLines = sections.get("");
179      if (ine(rootLines)) {
180         // Filter out the top-level heading (level)
181         var tableLines = rootLines.stream()
182            .filter(l -> !isHeadingLine(l, level))
183            .toList();
184
185         // Parse the key/value table into the bean
186         var tableRows = tableLines.stream().filter(l -> l.trim().startsWith("|")).toList();
187         if (!tableRows.isEmpty()) {
188            var headers = splitTableRow(tableRows.get(0));
189            var isKeyValue = headers.size() == 2
190               && (eqic(headers.get(0), CONST_Property) || eqic(headers.get(0), "Key"))
191               && eqic(headers.get(1), CONST_Value);
192
193            if (isKeyValue) {
194               var dataLines = tableRows.stream().skip(1).filter(l -> !isSeparatorRow(l)).toList();
195               for (var line : dataLines) {
196                  var cells = splitTableRow(line);
197                  if (cells.size() < 2)
198                     continue;
199                  var key = cells.get(0);
200                  var rawVal = cells.get(1);
201                  var pm = m.getPropertyMeta(key);
202                  if (pm != null) {
203                     setCurrentProperty(pm);
204                     var val = readCellValue(rawVal, (ClassMeta<?>) pm.getBeanInfo(), m.getBean(false));
205                     pm.set(m, key, val);
206                     setCurrentProperty(null);
207                  } else {
208                     onUnknownProperty(key, m, rawVal);
209                  }
210               }
211            }
212         }
213      }
214
215      // Process sub-sections (each maps to a nested bean property)
216      for (var entry : sections.entrySet()) {
217         var sectionName = entry.getKey();
218         if (sectionName.isEmpty())
219            continue; // skip root section already processed
220
221         var sectionLines = entry.getValue();
222         var pm = m.getPropertyMeta(sectionName);
223         if (pm == null)
224            continue; // Skip root, unknown properties, or sections we ignore
225
226         setCurrentProperty(pm);
227         var propCm = (ClassMeta<?>) pm.getBeanInfo();
228
229         // Find first content line to determine if it's a table or list
230         var contentLines = sectionLines.stream()
231            .filter(l -> !isHeadingLine(l, level + 1) && !l.isBlank())
232            .toList();
233
234         Object propVal;
235         if (!contentLines.isEmpty() && contentLines.get(0).trim().startsWith("|")) {
236            // Table content
237            var tableRows = sectionLines.stream().filter(l -> l.trim().startsWith("|")).toList();
238            if (!tableRows.isEmpty()) {
239               var tableHeaders = splitTableRow(tableRows.get(0));
240               var isKeyValue = tableHeaders.size() == 2
241                  && (eqic(tableHeaders.get(0), CONST_Property) || eqic(tableHeaders.get(0), "Key"))
242                  && eqic(tableHeaders.get(1), CONST_Value);
243
244               if (isKeyValue && propCm.isBean()) {
245                  // Nested bean via key/value table
246                  propVal = readDocAnything(sectionLines, (ClassMeta<Object>) propCm, m.getBean(), level + 1);
247               } else {
248                  // Key/value table (non-bean) or multi-column table → list or array
249                  propVal = readTable(sectionLines, propCm, m.getBean());
250               }
251            } else {
252               propVal = null;
253            }
254         } else if (!contentLines.isEmpty() && isBulletLine(contentLines.get(0))) {
255            // List content
256            propVal = readBulletList(sectionLines, propCm, m.getBean());
257         } else {
258            propVal = propCm.isBean()
259               ? readDocAnything(sectionLines, (ClassMeta<Object>) propCm, m.getBean(), level + 1)
260               : readAnything(sectionLines, (ClassMeta<Object>) propCm, m.getBean(), pm);
261         }
262
263         pm.set(m, sectionName, propVal);
264         setCurrentProperty(null);
265      }
266
267      return checkRequired(m).getBean();
268   }
269
270   /**
271    * Parses a map from Markdown document structure.
272    *
273    * @param lines All document lines.
274    * @param sType The map type.
275    * @param outer The outer object.
276    * @param level The current heading level.
277    * @return The parsed map.
278    * @throws ParseException If parsing fails.
279    */
280   @SuppressWarnings({
281      "java:S3740" // Raw Map needed for generic map construction from ClassMeta
282   })
283   protected Object readMapFromDoc(List<String> lines, ClassMeta<?> sType, Object outer, int level) throws IOException, ParseException {
284      // For maps, use the key/value table approach at the root level, then add sub-section maps
285      Map result = sType.canCreateNewInstance(outer) ? (Map) sType.newInstance(outer) : newGenericMap();
286      var keyType = sType.getKeyType() != null ? sType.getKeyType() : string();
287      var valueType = sType.getValueType() != null ? sType.getValueType() : object();
288
289      var sections = segmentBySections(lines, level + 1);
290      var rootLines = sections.get("");
291      if (rootLines != null) {
292         var tableRows = rootLines.stream().filter(l -> l.trim().startsWith("|")).toList();
293         if (!tableRows.isEmpty()) {
294            var headers = splitTableRow(tableRows.get(0));
295            var isKeyValue = headers.size() == 2
296               && (eqic(headers.get(0), CONST_Property) || eqic(headers.get(0), "Key"))
297               && eqic(headers.get(1), CONST_Value);
298
299            if (isKeyValue) {
300               var dataLines = tableRows.stream().skip(1).filter(l -> !isSeparatorRow(l)).toList();
301               for (var line : dataLines) {
302                  var cells = splitTableRow(line);
303                  if (cells.size() < 2)
304                     continue;
305                  var key = convertAttrToType(result, cells.get(0), keyType);
306                  var val = readCellValue(cells.get(1), valueType, null);
307                  result.put(key, val);
308               }
309            }
310         }
311      }
312
313      for (var entry : sections.entrySet()) {
314         var sectionName = entry.getKey();
315         if (sectionName.isEmpty())
316            continue;
317         var key = convertAttrToType(result, sectionName, keyType);
318         var val = readAnything(entry.getValue(), (ClassMeta<Object>) valueType, result, null);
319         result.put(key, val);
320      }
321
322      return result;
323   }
324
325   /**
326    * Segments document lines into sections by heading level.
327    *
328    * <p>
329    * Returns a map where the key is the heading text and the value is the lines in that section.
330    * The empty-string key maps to content before the first sub-heading (root content).
331    *
332    * @param lines All document lines.
333    * @param sectionLevel The heading level that defines section boundaries.
334    * @return Map of section name → section lines.
335    */
336   protected Map<String,List<String>> segmentBySections(List<String> lines, int sectionLevel) {
337      var result = new LinkedHashMap<String,List<String>>();
338      var currentSection = "";
339      var currentLines = new ArrayList<String>();
340      result.put("", currentLines);
341
342      for (var line : lines) {
343         var headingText = getHeadingText(line, sectionLevel);
344         if (headingText != null) {
345            currentSection = headingText;
346            currentLines = new ArrayList<>();
347            result.put(currentSection, currentLines);
348         } else {
349            currentLines.add(line);
350         }
351      }
352
353      return result;
354   }
355
356   /**
357    * Returns the heading text if the given line is a heading at the specified level, else null.
358    *
359    * @param line The input line.
360    * @param level The heading level to check.
361    * @return The heading text or null.
362    */
363   protected String getHeadingText(String line, int level) {
364      var prefix = "#".repeat(level) + " ";
365      var trimmed = line.trim();
366      if (trimmed.startsWith(prefix) && (trimmed.length() == prefix.length() || trimmed.charAt(prefix.length()) != '#'))
367         return trimmed.substring(prefix.length()).trim();
368      return null;
369   }
370
371   /**
372    * Returns whether the given line is a heading at the specified level.
373    *
374    * @param line The input line.
375    * @param level The heading level.
376    * @return Whether it is a heading at that level.
377    */
378   protected boolean isHeadingLine(String line, int level) {
379      return getHeadingText(line, level) != null;
380   }
381
382   /**
383    * Returns whether a line is a bullet list item.
384    *
385    * @param line The input line.
386    * @return Whether it is a bullet item.
387    */
388   protected boolean isBulletLine(String line) {
389      var trimmed = line.trim();
390      return trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.startsWith("+ ");
391   }
392}