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}