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.hocon;
018
019import static org.apache.juneau.commons.utils.StringUtils.*;
020
021import java.io.*;
022
023import org.apache.juneau.commons.lang.*;
024import org.apache.juneau.marshall.*;
025import org.apache.juneau.marshall.serializer.*;
026
027/**
028 * Specialized writer for serializing HOCON format.
029 *
030 * <p>
031 * Extends {@link SerializerWriter} with HOCON-specific methods for unquoted strings,
032 * triple-quoted strings ("""), equals sign separator, and formatting options.
033 *
034 * <h5 class='section'>See Also:</h5><ul>
035 *    <li class='link'><a class="doclink" href="https://github.com/lightbend/config/blob/main/HOCON.md">HOCON Specification</a>
036 * </ul>
037 */
038@SuppressWarnings({
039   "java:S135", // Multiple break/continue necessary for isNumber char validation loop
040   "resource" // Writer resource managed by calling code
041})
042public class HoconWriter extends SerializerWriter<HoconWriter> {
043
044   private static final AsciiSet QUOTE_KEY_CHARS = AsciiSet.of(" \t\n\r{},:[]=\"'#/");
045   // HOCON parser-meaningful chars that must be quoted in a value to avoid mis-tokenization:
046   //   `=` — alternate key-value assignment operator (also BASE64 padding).
047   //   `+` — first half of PLUS_EQUALS, and forbidden in unquoted strings (also Calendar
048   //         BASIC_ISO_DATE timezone offset, e.g. `20240615+0900`).
049   //   `$` — first half of `${...}` substitution, and forbidden in unquoted strings (also
050   //         ClassFormat.BINARY_NAME inner-class delimiter, e.g. `java.util.Map$Entry`).
051   //   `^ \ ? ! @ * &` — forbidden in unquoted strings by the HOCON tokenizer/spec, so values
052   //         containing them must be quoted for serializer->parser round-trip fidelity.
053   private static final AsciiSet QUOTE_VALUE_CHARS = AsciiSet.of(" \t\n\r{}[],:=+$\"'#^\\?!@*&");
054   private static final AsciiSet ENCODED_CHARS = AsciiSet.of("\n\t\b\f\r\"\\");
055
056   /** Use newlines instead of commas between members. */
057   protected final boolean useNewlineSeparators;
058
059   /** Use unquoted strings for simple values. */
060   protected final boolean useUnquotedStrings;
061
062   /** Use unquoted keys when safe. */
063   protected final boolean useUnquotedKeys;
064
065   /** Omit root braces. */
066   protected final boolean omitRootBraces;
067
068   /** Use triple-quoted """ for strings with newlines. */
069   protected final boolean useMultilineStrings;
070
071   /** Use = (not :) for key-value separator. */
072   protected final boolean useEqualsSign;
073
074   /**
075    * Constructor.
076    *
077    * @param out The writer being wrapped.
078    * @param useWhitespace If <jk>true</jk>, use indentation and newlines.
079    * @param maxIndent The maximum indentation level.
080    * @param trimStrings If <jk>true</jk>, trim strings before serialization.
081    * @param useNewlineSeparators Use newlines instead of commas.
082    * @param useUnquotedStrings Use unquoted strings for simple values.
083    * @param useUnquotedKeys Use unquoted keys when safe.
084    * @param omitRootBraces Omit root object braces.
085    * @param useMultilineStrings Use """ for multiline strings.
086    * @param useEqualsSign Use = for key-value separator.
087    * @param uriResolver The URI resolver.
088    */
089   @SuppressWarnings({
090      "java:S107" // Constructor requires many parameters for HOCON writer configuration
091   })
092   protected HoconWriter(Writer out, boolean useWhitespace, int maxIndent, boolean trimStrings,
093         boolean useNewlineSeparators, boolean useUnquotedStrings, boolean useUnquotedKeys,
094         boolean omitRootBraces, boolean useMultilineStrings, boolean useEqualsSign,
095         UriResolver uriResolver) {
096      super(out, useWhitespace, maxIndent, trimStrings, '"', uriResolver);
097      this.useNewlineSeparators = useNewlineSeparators;
098      this.useUnquotedStrings = useUnquotedStrings;
099      this.useUnquotedKeys = useUnquotedKeys;
100      this.omitRootBraces = omitRootBraces;
101      this.useMultilineStrings = useMultilineStrings;
102      this.useEqualsSign = useEqualsSign;
103   }
104
105   /**
106    * Writes the key (unquoted or quoted).
107    *
108    * @param key The key to write.
109    *    <br>Can be <jk>null</jk> (treated as an empty string).
110    * @return This object.
111    */
112   public HoconWriter key(String key) {
113      if (key == null)
114         key = "";
115      if (needsQuoting(key))
116         quotedString(key);
117      else
118         unquotedString(key);
119      return this;
120   }
121
122   /**
123    * Writes the key-value separator (= or :).
124    *
125    * @return This object.
126    */
127   public HoconWriter equalsSign() {
128      if (useEqualsSign)
129         w(" = ");
130      else
131         w(": ");
132      return this;
133   }
134
135   /**
136    * Writes object start.
137    *
138    * @param depth The current indentation depth.
139    * @return This object.
140    */
141   public HoconWriter objectStart(int depth) {
142      w('{');
143      if (useWhitespace)
144         nl(depth + 1);
145      return this;
146   }
147
148   /**
149    * Writes object end.
150    *
151    * @param depth The current indentation depth.
152    * @return This object.
153    */
154   public HoconWriter objectEnd(int depth) {
155      if (useWhitespace)
156         nl(depth);
157      w('}');
158      return this;
159   }
160
161   /**
162    * Writes array start.
163    *
164    * @return This object.
165    */
166   public HoconWriter arrayStart() {
167      w('[');
168      return this;
169   }
170
171   /**
172    * Writes array end.
173    *
174    * @return This object.
175    */
176   public HoconWriter arrayEnd() {
177      w(']');
178      return this;
179   }
180
181   /**
182    * Writes member separator (newline or comma).
183    *
184    * @param depth The current indentation depth.
185    * @return This object.
186    */
187   public HoconWriter separator(int depth) {
188      if (useNewlineSeparators)
189         nl(depth);
190      else
191         w(',');
192      return this;
193   }
194
195   /**
196    * Returns whether the key needs quoting.
197    *
198    * @param key The key to check.
199    *    <br>Can be <jk>null</jk> (returns <jk>true</jk>).
200    * @return <jk>true</jk> if the key must be quoted.
201    */
202   public boolean needsQuoting(String key) {
203      if (isEmpty(key))
204         return true;
205      if (!useUnquotedKeys)
206         return true;
207      for (var i = 0; i < key.length(); i++) {
208         if (QUOTE_KEY_CHARS.contains(key.charAt(i)))
209            return true;
210      }
211      return "true".equals(key) || "false".equals(key) || "null".equals(key) || isNumber(key);
212   }
213
214   /**
215    * Returns whether the value can be written as unquoted.
216    *
217    * @param value The value to check.
218    *    <br>Can be <jk>null</jk> (returns <jk>false</jk>).
219    * @return <jk>true</jk> if the value can be unquoted.
220    */
221   public boolean isSimpleValue(String value) {
222      if (isEmpty(value))
223         return false;
224      if (!useUnquotedStrings)
225         return false;
226      if (value.startsWith("{") || value.startsWith("[") || value.startsWith("\"") || value.startsWith("'"))
227         return false;
228      if (value.startsWith("#") || value.startsWith("//") || value.startsWith("$"))
229         return false;
230      if (value.contains("\n") || value.contains("\r"))
231         return false;
232      for (var i = 0; i < value.length(); i++) {
233         if (QUOTE_VALUE_CHARS.contains(value.charAt(i)))
234            return false;
235      }
236      if ("true".equals(value) || "false".equals(value) || "null".equals(value))
237         return false;
238      return !isNumber(value);
239   }
240
241   private static boolean isNumber(String s) {
242      if (isEmpty(s))
243         return false;
244      var i = s.startsWith("-") ? 1 : 0;
245      if (i >= s.length())
246         return false;
247      if (s.charAt(i) == '0' && s.length() > i + 1 && s.charAt(i + 1) != '.')
248         return false;
249      for (; i < s.length(); i++) {
250         var c = s.charAt(i);
251         if (c >= '0' && c <= '9')
252            continue;
253         if (c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
254            continue;
255         return false;
256      }
257      try {
258         Double.parseDouble(s);
259         return true;
260      } catch (@SuppressWarnings("unused") NumberFormatException e) {
261         return false;
262      }
263   }
264
265   /**
266    * Writes an unquoted string value.
267    *
268    * @param value The value to write.
269    *    <br>Can be <jk>null</jk> (nothing is written).
270    * @return This object.
271    */
272   public HoconWriter unquotedString(String value) {
273      if (value != null)
274         w(value);
275      return this;
276   }
277
278   /**
279    * Writes a quoted string with JSON escaping.
280    *
281    * @param value The value to write.
282    *    <br>Can be <jk>null</jk> (emitted as the literal <js>"null"</js>).
283    * @return This object.
284    */
285   public HoconWriter quotedString(String value) {
286      if (value == null) {
287         w("null");
288         return this;
289      }
290      w('"');
291      for (var i = 0; i < value.length(); i++) {
292         var c = value.charAt(i);
293         if (ENCODED_CHARS.contains(c)) {
294            w('\\');
295            w(switch (c) {
296               case '\n' -> 'n';
297               case '\t' -> 't';
298               case '\b' -> 'b';
299               case '\f' -> 'f';
300               case '\r' -> 'r';
301               case '"' -> '"';
302               case '\\' -> '\\';
303               default -> c;
304            });
305         } else {
306            w(c);
307         }
308      }
309      w('"');
310      return this;
311   }
312
313   /**
314    * Writes a triple-quoted string using """ syntax.
315    *
316    * @param value The value to write.
317    *    <br>Can be <jk>null</jk> (emitted as the literal <js>"null"</js>).
318    * @return This object.
319    */
320   public HoconWriter tripleQuotedString(String value) {
321      if (value == null) {
322         w("null");
323         return this;
324      }
325      w("\"\"\"");
326      w(value.replace("\r\n", "\n").replace("\r", "\n"));
327      w("\"\"\"");
328      return this;
329   }
330}