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}