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.utils.Shorts.*;
020
021import java.io.*;
022import java.time.*;
023import java.time.temporal.*;
024import java.util.*;
025import java.util.function.*;
026
027import org.apache.juneau.commons.bean.*;
028import org.apache.juneau.commons.utils.*;
029import org.apache.juneau.marshall.*;
030import org.apache.juneau.marshall.json.*;
031import org.apache.juneau.marshall.json5.*;
032import org.apache.juneau.marshall.serializer.*;
033import org.apache.juneau.marshall.stream.*;
034
035/**
036 * Session for serializing objects to INI format.
037 */
038@SuppressWarnings({
039   "java:S110", // Inheritance depth acceptable for serializer session hierarchy
040   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
041   "java:S3776", // Cognitive complexity acceptable for serialization logic
042   "java:S6541", // Acceptable for session implementation
043   "resource" // IniWriter lifecycle managed by SerializerPipe
044})
045public class IniSerializerSession extends WriterSerializerSession implements RecordWritable {
046
047   private static final String SECTION_PATH_DELIMITER = "/";
048
049   /**
050    * Builder for INI serializer session.
051    */
052   public static class Builder extends WriterSerializerSession.Builder<Builder> {
053
054      private final IniSerializer ctx;
055
056      protected Builder(IniSerializer ctx) {
057         super(reqnn("ctx", ctx));
058         this.ctx = ctx;
059      }
060
061      @Override
062      public IniSerializerSession build() {
063         return new IniSerializerSession(this);
064      }
065   }
066
067   /**
068    * Creates a session builder.
069    *
070    * @param ctx The serializer context.
071    *    <br>Cannot be <jk>null</jk>.
072    * @return The builder.
073    */
074   public static Builder create(IniSerializer ctx) {
075      return new Builder(reqnn("ctx", ctx));
076   }
077
078   private final IniSerializer ctx;
079
080   protected IniSerializerSession(Builder builder) {
081      super(builder);
082      ctx = builder.ctx;
083   }
084
085   @Override /* RecordWritable */
086   public RecordWriter writeRecords(Object output) throws IOException {
087      return RecordAdapter.writer(this, output);
088   }
089
090   @Override /* RecordWritable */
091   public boolean isRecordStreaming() {
092      return false;
093   }
094
095   @Override
096   protected void doWrite(SerializerPipe out, Object o) throws IOException, SerializeException {
097      if (o == null)
098         return;
099      var aType = getClassMetaForObject(o);
100      if (!aType.isBean() && !aType.isMap())
101         throw new SerializeException(this, "INI format requires a bean or Map<String,?> at root. Got: %s", aType.inner().getName());
102      var w = getIniWriter(out);
103      var eType = getExpectedRootType(o);
104      if (isAddBeanTypes() || isAddRootType()) {
105         var typeName = getBeanTypeName(this, eType, aType, null);
106         if (typeName == null)
107            typeName = aType.inner().getSimpleName();
108         var typeKey = getBeanTypePropertyName(eType);
109         w.keyValue(typeKey, typeName);
110      }
111      if (aType.isBean())
112         writeBean(w, toBeanMap(o), "");
113      else
114         writeMapAtRoot(w, (Map<?,?>)o, eType);
115   }
116
117   protected final IniWriter getIniWriter(SerializerPipe out) {
118      var output = out.getRawOutput();
119      if (output instanceof IniWriter output2)
120         return output2;
121      var w = new IniWriter(out.getWriter(), isUseWhitespace(), getMaxIndent(), isTrimStrings(),
122         ctx.kvSeparator, ctx.spacedSeparator, getUriResolver());
123      out.setWriter(w);
124      return w;
125   }
126
127   private void writeBean(IniWriter w, BeanMap<?> m, String sectionPath) throws IOException, SerializeException {
128      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
129      var simple = new ArrayList<Map.Entry<BeanPropertyMeta,Object>>();
130      var sections = new ArrayList<Map.Entry<BeanPropertyMeta,Object>>();
131
132      m.forEachValue(checkNull, (pMeta, key, value, thrown) -> {
133         if (nn(thrown))
134            onBeanGetterException(pMeta, thrown);
135         var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
136         if (canIgnoreValue(pMeta, key, value))
137            return;
138         var aType = value == null ? cMeta : getClassMetaForObject(value, cMeta);
139         var iniMeta = ctx.getIniBeanPropertyMeta(pMeta);
140         // Collections/arrays are written as inline key-value (not sections), so they must appear in
141         // the default section before any [section] headers for correct parsing
142         if (iniMeta.isJson5Encoding() || isSimpleOrJson5Inline(aType)
143            || aType.isCollectionOrArrayOrOptional()) {
144            simple.add(new AbstractMap.SimpleEntry<>(pMeta, value));
145         } else {
146            sections.add(new AbstractMap.SimpleEntry<>(pMeta, value));
147         }
148      });
149
150      // Pass 1: simple properties and JSON5-inline values
151      for (var e : simple) {
152         BeanPropertyMeta pMeta = e.getKey();
153         if (ctx.useComments) {
154            var iniMeta = ctx.getIniBeanPropertyMeta(pMeta);
155            if (ine(iniMeta.getComment()))
156               w.comment(iniMeta.getComment());
157         }
158         writeKeyValue(w, pMeta.getName(), e.getValue(), pMeta);
159      }
160
161      // Pass 2: bean and Map sections
162      for (var e : sections) {
163         BeanPropertyMeta pMeta = e.getKey();
164         Object value = e.getValue();
165         var iniMeta = ctx.getIniBeanPropertyMeta(pMeta);
166         var key = ine(iniMeta.getSection()) ? iniMeta.getSection() : pMeta.getName();
167         var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
168         var newPath = sectionPath.isEmpty() ? key : sectionPath + SECTION_PATH_DELIMITER + key;
169
170         if (nn(value)) {
171            if (isUseWhitespace() && !sectionPath.isEmpty())
172               w.blankLine();
173            var aType = getClassMetaForObject(value, cMeta);
174            if (aType.isBean()) {
175               w.section(newPath);
176               writeBean(w, toBeanMap(value), newPath);
177            } else if (aType.isMap()) {
178               var map = (Map<?,?>)value;
179               if (isSimpleMap(map, aType)) {
180                  w.section(newPath);
181                  writeMapSection(w, map);
182               } else {
183                  if (ctx.useComments && ine(iniMeta.getComment()))
184                     w.comment(iniMeta.getComment());
185                  writeKeyValue(w, pMeta.getName(), value, pMeta);
186               }
187            } else {
188               // Collections and arrays, plus anything that is neither bean, map, collection, nor array
189               // (e.g. a streamable-but-not-collection value such as a Stream). The latter isn't classified
190               // as "simple" at classification time, so it falls through to the "sections" pass here --
191               // write it as an inline key-value rather than silently dropping it.
192               if (ctx.useComments && ine(iniMeta.getComment()))
193                  w.comment(iniMeta.getComment());
194               writeKeyValue(w, pMeta.getName(), value, pMeta);
195            }
196         } else if (isKeepNullProperties()) {
197            if (ctx.useComments && ine(iniMeta.getComment()))
198               w.comment(iniMeta.getComment());
199            writeKeyValue(w, pMeta.getName(), null, pMeta);
200         }
201      }
202   }
203
204   private void writeMapAtRoot(IniWriter w, Map<?,?> map, ClassMeta<?> type) throws SerializeException {
205      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
206      forEachEntry(map, e -> {
207         var k = toString(e.getKey());
208         var v = e.getValue();
209         if (!checkNull.test(v))
210            return;
211         try {
212            var aType = getClassMetaForObject(v, type);
213            if (aType.isBean()) {
214               if (isUseWhitespace())
215                  w.blankLine();
216               w.section(k);
217               writeBean(w, toBeanMap(v), k);
218            } else if (aType.isMap() && v != null) {
219               var nested = (Map<?,?>)v;
220               if (isSimpleMap(nested, aType)) {
221                  if (isUseWhitespace())
222                     w.blankLine();
223                  w.section(k);
224                  writeMapSection(w, nested);
225               } else {
226                  writeKeyValue(w, k, v, null);
227               }
228            } else {
229               writeKeyValue(w, k, v, null);
230            }
231         } catch (SerializeException | IOException ex) {
232            throw rex(ex);
233         }
234      });
235   }
236
237   private void writeMapSection(IniWriter w, Map<?,?> map) throws SerializeException {
238      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
239      forEachEntry(map, e -> {
240         var k = toString(e.getKey());
241         var v = e.getValue();
242         if (!checkNull.test(v))
243            return;
244         writeKeyValue(w, k, v, null);
245      });
246   }
247
248   private void writeKeyValue(IniWriter w, String key, Object value, BeanPropertyMeta pMeta) throws SerializeException {
249      ClassMeta<?> cMeta;
250      if (pMeta != null)
251         cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
252      else if (value != null)
253         cMeta = getClassMetaForObject(value);
254      else
255         cMeta = object();
256      var aType = value == null ? cMeta : getClassMetaForObject(value, cMeta);
257
258      var swap = aType.getSwap(this);
259      if (nn(swap)) {
260         value = swap(swap, value);
261         aType = swap.getSwapClassMeta(this);
262         if (aType.isObject())
263            aType = getClassMetaForObject(value);
264      }
265
266      if (value == null && !isKeepNullProperties())
267         return;
268
269      String valueStr;
270      if (value == null)
271         valueStr = "null";
272      else if (isSimpleOrJson5Inline(aType))
273         valueStr = formatSimpleValue(value, aType);
274      else
275         valueStr = encodeComplexValue(value);
276      w.keyValue(key, valueStr);
277   }
278
279   private String formatSimpleValue(Object value, ClassMeta<?> aType) throws SerializeException {
280      if (aType.isCharSequence() || aType.isUri()) {
281         var s = toString(value);
282         return needsQuoting(s) ? "'" + s.replace("'", "''") + "'" : s;
283      }
284      if (aType.isNumber())
285         return value.toString();
286      if (aType.isBoolean())
287         return ((Boolean)value).toString();
288      if (aType.isEnum())
289         return ((Enum<?>)value).name();
290      if (aType.isDate())
291         return writeDate((Date)value, aType);
292      if (aType.isCalendar())
293         return writeCalendar(value, aType);
294      if (aType.isTemporal())
295         return writeTemporal((TemporalAccessor)value, aType);
296      if (aType.isDuration())
297         return writeDuration((Duration)value);
298      if (aType.isPeriod())
299         return writePeriod((Period)value);
300      return toString(value);
301   }
302
303   private static boolean needsQuoting(String s) {
304      if (ie(s))
305         return true;
306      if (eq(s, "null") || eqic(s, "true") || eqic(s, "false"))
307         return true;
308      // Whole-string numeric check; mirrors -?\d+(\.\d+)?([eE][+-]?\d+)? (integer part allows leading zeros).
309      if (StringUtils.matchNumberPrefix(s, true) == s.length())
310         return true;
311      if (s.contains("=") || s.contains("[") || s.contains("]") || s.contains("#") || s.contains("\n"))
312         return true;
313      var trimmed = s.trim();
314      return !trimmed.equals(s);
315   }
316
317   private String encodeComplexValue(Object value) throws SerializeException {
318      return getJson5Serializer().write(value);
319   }
320
321   private JsonSerializer getJson5Serializer() {
322      var b = Json5Serializer.create().marshallingContext((MarshallingContext)getContext());
323      if (isAddBeanTypes())
324         b.addBeanTypes();
325      if (isAddRootType())
326         b.addRootType();
327      return b.build();
328   }
329
330   private static boolean isSimpleOrJson5Inline(ClassMeta<?> aType) {
331      if (aType.isBean() || aType.isMap())
332         return false;
333      if (aType.isCollection() || aType.isArray())
334         return false;
335      return !aType.isStreamable();
336   }
337
338   private boolean isSimpleMap(Map<?,?> map, ClassMeta<?> mapType) {
339      if (map == null)
340         return false;
341      var keyType = mapType.getKeyType();
342      // When keyType is null or Object, check keys at runtime; otherwise require CharSequence
343      if (keyType != null && !keyType.isCharSequence() && !keyType.isObject())
344         return false;
345      for (Object k : map.keySet()) {
346         if (k != null && !(k instanceof CharSequence))
347            return false;
348      }
349      for (Object v : map.values()) {
350         if (v != null) {
351            ClassMeta<?> vType = getClassMetaForObject(v);
352            if (vType.isBean() || vType.isMap() || vType.isCollectionOrArray())
353               return false;
354         }
355      }
356      return true;
357   }
358}