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.ini; 018 019import static org.apache.juneau.commons.function.Suppliers.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.io.*; 023import java.util.*; 024import java.util.Map.*; 025import java.util.regex.*; 026 027import org.apache.juneau.commons.bean.*; 028import org.apache.juneau.commons.function.*; 029import org.apache.juneau.commons.reflect.*; 030import org.apache.juneau.marshall.*; 031import org.apache.juneau.marshall.collections.*; 032import org.apache.juneau.marshall.json.*; 033import org.apache.juneau.marshall.json5.*; 034import org.apache.juneau.marshall.parser.*; 035import org.apache.juneau.marshall.stream.*; 036 037/** 038 * Session for parsing INI format into POJOs. 039 */ 040@SuppressWarnings({ 041 "java:S135", // The INI line-reading, populateBean() and buildMapFromSections() loops use several 'continue's to skip blank lines, comments, nested sections and already-handled keys. 042 "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants 043 "java:S3776", // Branching is inherent to populateBean(), buildMapFromSections() and readValue(), which dispatch over section nesting and target value types. 044 "java:S6541", // populateBean() is a single dispatch over bean properties, sub-sections and value types; splitting it would scatter the INI section mapping. 045 "resource", // Closeable resources are owned by the caller's parser session; Eclipse JDT @Owning warning is by design. 046 "unchecked" // The (T) cast of convertMapToTarget() in doRead() is safe because the target type is the Map type the result was built for. 047}) 048public class IniParserSession extends ReaderParserSession implements RecordReadable { 049 050 /** Delimiter for nested section names (e.g. {@code address/street}). */ 051 private static final String SECTION_PATH_DELIMITER = "/"; 052 053 private final Memoizer<JsonParser> json5Parser = memoizer( 054 () -> Json5Parser.create().marshallingContext((MarshallingContext) getContext()).build() 055 ); 056 057 /** 058 * Builder for INI parser session. 059 */ 060 public static class Builder extends ReaderParserSession.Builder<Builder> { 061 062 private final IniParser ctx; 063 064 protected Builder(IniParser ctx) { 065 super(reqnn("ctx", ctx)); 066 this.ctx = ctx; 067 } 068 069 @Override 070 public IniParserSession build() { 071 return new IniParserSession(this); 072 } 073 } 074 075 /** 076 * Creates a session builder. 077 * 078 * @param ctx The parser context. 079 * <br>Cannot be <jk>null</jk>. 080 * @return The builder. 081 */ 082 public static Builder create(IniParser ctx) { 083 return new Builder(reqnn("ctx", ctx)); 084 } 085 086 private final IniParser ctx; 087 088 protected IniParserSession(Builder builder) { 089 super(builder); 090 ctx = builder.ctx; 091 } 092 093 @Override /* RecordReadable */ 094 public RecordReader readRecords(Object input) throws IOException { 095 return RecordAdapter.reader(this, input); 096 } 097 098 @Override /* RecordReadable */ 099 public boolean isRecordStreaming() { 100 return false; 101 } 102 103 @Override 104 protected <T> T doRead(ParserPipe pipe, ClassMeta<T> type) throws IOException, ParseException, ExecutableException { 105 try (Reader r = pipe.getParserReader()) { 106 if (r == null) 107 return null; 108 var sections = readIniContent(r); 109 if (!type.isBean() && !type.isMap()) 110 throw new ParseException(this, "INI format requires bean or Map<String,?> target. Got: %s", type.inner().getName()); 111 if (type.isMap()) { 112 var result = buildMapFromSections(sections, ""); 113 return (T) convertMapToTarget(result, type); 114 } 115 var bm = toBeanMap(type.newInstance(getOuter())); 116 populateBean(bm, sections, ""); 117 return type.cast(checkRequired(bm).getBean()); 118 } 119 } 120 121 /** 122 * Parses INI content into section -> (key -> raw value) structure. 123 * 124 * @param r The reader. 125 * <br>Must not be <jk>null</jk>. 126 * @return Map of section name to key-value map. Default section uses "". 127 */ 128 protected Map<String,Map<String,String>> readIniContent(Reader r) throws IOException { 129 var sections = new LinkedHashMap<String,Map<String,String>>(); 130 var current = sections.computeIfAbsent("", k -> m()); 131 var br = r instanceof BufferedReader r2 ? r2 : new BufferedReader(r); 132 String line; 133 while ((line = br.readLine()) != null) { 134 line = line.trim(); 135 if (line.isEmpty() || line.startsWith("#")) 136 continue; 137 if (line.startsWith("[")) { 138 var end = line.indexOf(']'); 139 if (end < 0) 140 continue; 141 var sectionName = line.substring(1, end).trim(); 142 current = sections.computeIfAbsent(sectionName, k -> m()); 143 continue; 144 } 145 var kv = splitKeyValue(line); 146 if (kv != null) { 147 var key = kv[0].trim(); 148 var value = kv[1].trim(); 149 // Strip inline comment (# ...) from value (when not inside quotes) 150 value = stripInlineComment(value); 151 current.put(key, value); 152 } 153 } 154 return sections; 155 } 156 157 /** 158 * Splits an INI {@code key=value} / {@code key:value} (or configured-separator) line into its key and 159 * raw-value parts. 160 * 161 * <p> 162 * Behavior mirrors the former {@code ^([^=#\s][^=]*)\s*[=:]\s*(.*)$} regex but without its super-linear 163 * backtracking: when this session's {@link IniParser.Builder#kvSeparator(char) kvSeparator} is left at its 164 * default (<c>=</c>), an {@code '='} delimiter (if present) binds to the first {@code '='}; otherwise the 165 * last {@code ':'} is used (unchanged legacy behavior). When a non-default {@code kvSeparator} has been 166 * configured (e.g. to round-trip an {@link IniSerializer} that was configured with a matching 167 * {@code kvSeparator}), only that character is recognized as the delimiter. Surrounding whitespace is left 168 * in place (callers trim). 169 * 170 * @param line The (already-trimmed) candidate line. 171 * @return A two-element array of {raw-key, raw-value}, or <jk>null</jk> if the line is not a key/value pair. 172 */ 173 @SuppressWarnings({ 174 "java:S1168" // null is a distinct "not a key/value pair" sentinel; the caller guards on != null before indexing, so an empty array would change parse semantics. 175 }) 176 private String[] splitKeyValue(String line) { 177 if (line.isEmpty()) 178 return null; 179 var first = line.charAt(0); 180 if (first == '=' || first == '#' || Character.isWhitespace(first)) 181 return null; 182 var sep = ctx.kvSeparator; 183 var idx = line.indexOf(sep); 184 if (idx < 0 && sep == '=') 185 idx = line.lastIndexOf(':'); 186 if (idx < 1) 187 return null; 188 return new String[] { line.substring(0, idx), line.substring(idx + 1) }; 189 } 190 191 private void populateBean(BeanMap<?> bm, Map<String,Map<String,String>> sections, String sectionPath) throws ParseException, ExecutableException { 192 var defaultSection = sections.get(sectionPath); 193 if (defaultSection != null) { 194 for (Entry<String,String> e : defaultSection.entrySet()) { 195 var key = e.getKey(); 196 var rawValue = e.getValue(); 197 var pMeta = bm.getMeta().getProperties().get(key); 198 if (pMeta == null && isIgnoreUnknownBeanProperties()) 199 continue; 200 if (pMeta == null) 201 throw new ParseException(this, "Unknown property '%s'", key); 202 var value = readValue(rawValue, (ClassMeta<?>) pMeta.getBeanInfo()); 203 bm.put(key, value); 204 } 205 } 206 // Process nested sections 207 for (var entry : sections.entrySet()) { 208 var sectionName = entry.getKey(); 209 var sub = entry.getValue(); 210 if (sectionName.equals(sectionPath)) 211 continue; 212 var isChild = sectionPath.isEmpty() ? !sectionName.contains(SECTION_PATH_DELIMITER) 213 : sectionName.startsWith(sectionPath + SECTION_PATH_DELIMITER); 214 if (!isChild) 215 continue; 216 var childName = sectionPath.isEmpty() ? sectionName 217 : sectionName.substring(sectionPath.length() + SECTION_PATH_DELIMITER.length()).split(Pattern.quote(SECTION_PATH_DELIMITER))[0]; 218 var pMeta = bm.getMeta().getProperties().get(childName); 219 if (pMeta == null && isIgnoreUnknownBeanProperties()) 220 continue; 221 if (pMeta == null) 222 continue; 223 var cMeta = (ClassMeta<?>) pMeta.getBeanInfo(); 224 var childPath = sectionPath.isEmpty() ? childName : sectionPath + SECTION_PATH_DELIMITER + childName; 225 if (cMeta.isBean()) { 226 var child = toBeanMap(cMeta.newInstance(getOuter())); 227 populateBean(child, sections, childPath); 228 bm.put(childName, checkRequired(child).getBean()); 229 } else if (cMeta.isMap() && sub != null) { 230 var valueType = cMeta.getValueType(); 231 if (valueType == null) 232 valueType = object(); 233 var map = new LinkedHashMap<String,Object>(); 234 for (Entry<String,String> e : sub.entrySet()) 235 map.put(e.getKey(), readValue(e.getValue(), valueType)); 236 bm.put(childName, convertMapToTarget(map, cMeta)); 237 } else if (cMeta.isMap()) { 238 // sub == null: no literal header line for this section, only reachable via nested paths. 239 } else { 240 // The section header matched a real (known) bean property, but its declared type is neither 241 // a bean nor a map shape that a section's key/value content can be poured into (e.g. a plain 242 // scalar). Unlike an unmatched section name (see above), this is a genuine type mismatch on a 243 // property we DO know about, so surface it instead of silently dropping the section's data. 244 throw new ParseException(this, "Cannot populate property '%s' of type '%s' from INI section '%s'; expected a bean or map type.", childName, cMeta, sectionName); 245 } 246 } 247 } 248 249 private Map<String,Object> buildMapFromSections(Map<String,Map<String,String>> sections, String sectionPath) throws ParseException, ExecutableException { 250 var result = new LinkedHashMap<String,Object>(); 251 var defaultSection = sections.get(sectionPath); 252 if (defaultSection != null) { 253 for (Entry<String,String> e : defaultSection.entrySet()) 254 result.put(e.getKey(), readValue(e.getValue(), object())); 255 } 256 for (var entry : sections.entrySet()) { 257 var sectionName = entry.getKey(); 258 var childSection = entry.getValue(); 259 if (sectionName.equals(sectionPath)) 260 continue; 261 // Every non-root section is nested under "" by definition, so at the root level a 262 // grandchild-only section (e.g. "employment/company" with no literal "[employment]" header) 263 // must still be recognized as belonging to the "employment" subtree -- restricting root-level 264 // children to sections with no delimiter at all silently dropped that whole subtree. 265 var isChild = sectionPath.isEmpty() || sectionName.startsWith(sectionPath + SECTION_PATH_DELIMITER); 266 if (!isChild) 267 continue; 268 var childName = sectionPath.isEmpty() ? sectionName.split(Pattern.quote(SECTION_PATH_DELIMITER))[0] 269 : sectionName.substring(sectionPath.length() + SECTION_PATH_DELIMITER.length()).split(Pattern.quote(SECTION_PATH_DELIMITER))[0]; 270 if (result.containsKey(childName)) 271 continue; 272 var childPath = sectionPath.isEmpty() ? childName : sectionPath + SECTION_PATH_DELIMITER + childName; 273 var hasNested = sections.keySet().stream().anyMatch(s -> s.startsWith(childPath + SECTION_PATH_DELIMITER)); 274 if (hasNested) { 275 result.put(childName, buildMapFromSections(sections, childPath)); 276 } else if (childSection != null) { 277 var map = new LinkedHashMap<String,Object>(); 278 for (Entry<String,String> e : childSection.entrySet()) 279 map.put(e.getKey(), readValue(e.getValue(), object())); 280 result.put(childName, map); 281 } 282 } 283 return result; 284 } 285 286 private Object readValue(String raw, ClassMeta<?> targetType) throws ParseException, ExecutableException { 287 if (raw == null) 288 return null; 289 var trimmed = raw.trim(); 290 if (eq(trimmed, "null")) 291 return null; 292 if (eqic(trimmed, "true")) 293 return true; 294 if (eqic(trimmed, "false")) 295 return false; 296 if (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length() >= 2) { 297 var inner = trimmed.substring(1, trimmed.length() - 1).replace("''", "'"); 298 return convertToMemberType(null, inner, targetType); 299 } 300 if (trimmed.startsWith("[") || trimmed.startsWith("{")) 301 return getJson5Parser().read(trimmed, targetType); 302 if (targetType.isNumber()) { 303 try { 304 if (trimmed.contains(".") || trimmed.toLowerCase(Locale.ROOT).contains("e")) 305 return Double.parseDouble(trimmed); 306 return Long.parseLong(trimmed); 307 } catch (@SuppressWarnings("unused") NumberFormatException e) { 308 return convertToMemberType(null, trimmed, targetType); 309 } 310 } 311 if (targetType.isDate()) 312 return readDate(trimmed, targetType); 313 if (targetType.isCalendar()) 314 return readCalendar(trimmed, targetType); 315 if (targetType.isTemporal()) 316 return readTemporal(trimmed, targetType); 317 if (targetType.isDuration()) 318 return readDuration(trimmed); 319 if (targetType.isPeriod()) 320 return readPeriod(trimmed); 321 return convertToMemberType(null, trimmed, targetType); 322 } 323 324 /** 325 * Returns a Json5 parser configured with this session's {@link MarshallingContext}. 326 * 327 * <p> 328 * Required so that format hints carried on the parent {@code IniParser} context (e.g. 329 * {@link LocaleFormat}, {@link TimeZoneFormat}) are honored 330 * when nested JSON5-encoded collection/map values are deserialized. Using {@code Json5Parser.DEFAULT} 331 * here would silently drop those hints and fall back to the format-default behavior. 332 * 333 * <p> 334 * Memoized via {@link Memoizer} — first call constructs the parser; subsequent calls return 335 * the cached instance. {@link #readValue} calls this once per JSON5-encoded value, so a single 336 * builder-construction + parser-instantiation is amortized across the whole INI document rather 337 * than repeated for every {@code […]} / {@code {…}} value. 338 * 339 * <p> 340 * List-of-{@link Locale} values written under {@link LocaleFormat#UNDERSCORE} 341 * (wire form {@code "en_US"}) were being parsed via {@code Locale.forLanguageTag("en_US")} (BCP_47 default) 342 * which returns {@link Locale#ROOT}, dropping the locale content. 343 */ 344 private JsonParser getJson5Parser() { 345 return json5Parser.get(); 346 } 347 348 private static Object convertMapToTarget(Map<String,Object> map, ClassMeta<?> type) throws ParseException, ExecutableException { 349 if (JsonMap.class.isAssignableFrom(type.inner())) 350 return new JsonMap(map); 351 return map; 352 } 353 354 private static String stripInlineComment(String value) { 355 if (value == null || !value.contains("#")) 356 return value; 357 var inQuote = false; 358 for (var i = 0; i < value.length(); i++) { 359 var c = value.charAt(i); 360 if (c == '\'') 361 inQuote = !inQuote; 362 else if (c == '#' && !inQuote) 363 return value.substring(0, i).trim(); 364 } 365 return value; 366 } 367}