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.yaml;
018
019import static org.apache.juneau.commons.utils.StringUtils.*;
020
021import java.io.*;
022import java.util.*;
023
024import org.apache.juneau.commons.lang.*;
025import org.apache.juneau.marshall.*;
026import org.apache.juneau.marshall.serializer.*;
027
028/**
029 * Specialized writer for serializing YAML.
030 *
031 * <h5 class='section'>Notes:</h5><ul>
032 *    <li class='note'>
033 *       This class is not intended for external use.
034 * </ul>
035 *
036 * <h5 class='section'>See Also:</h5><ul>
037 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/YamlSupport">YAML</a>
038 * </ul>
039 */
040@SuppressWarnings({
041   "resource" // Writer resource managed by calling code
042})
043public class YamlWriter extends SerializerWriter<YamlWriter> {
044
045   private static final AsciiSet specialChars = AsciiSet.of(":#{}[],&*!|>'\"%@`");
046
047   // @formatter:off
048   private static final Set<String> reservedWords = new HashSet<>(Arrays.asList(
049      "null", "Null", "NULL", "~",
050      "true", "True", "TRUE",
051      "false", "False", "FALSE",
052      "yes", "Yes", "YES",
053      "no", "No", "NO",
054      "on", "On", "ON",
055      "off", "Off", "OFF",
056      ".inf", ".Inf", ".INF",
057      "-.inf", "-.Inf", "-.INF",
058      ".nan", ".NaN", ".NAN"
059   ));
060   // @formatter:on
061
062   private static final AsciiSet escapedChars = AsciiSet.of("\\\"\n\t\r\0\u0007\b\f\u001b");
063
064   /**
065    * Constructor.
066    *
067    * @param out The writer being wrapped.
068    * @param useWhitespace If <jk>true</jk>, tabs and spaces will be used in output.
069    * @param maxIndent The maximum indentation level.
070    * @param trimStrings If <jk>true</jk>, strings will be trimmed before being serialized.
071    * @param uriResolver The URI resolver for resolving URIs to absolute or root-relative form.
072    */
073   protected YamlWriter(Writer out, boolean useWhitespace, int maxIndent, boolean trimStrings, UriResolver uriResolver) {
074      super(out, useWhitespace, maxIndent, trimStrings, '"', uriResolver);
075   }
076
077   /**
078    * Writes a YAML mapping key.
079    *
080    * <p>
081    * If the key needs quoting (contains special YAML characters, starts/ends with whitespace,
082    * is empty, or is a YAML reserved word), it will be double-quoted with proper escaping.
083    * A colon-space separator is always appended after the key.
084    *
085    * @param s The key name.
086    *    <br>Can be <jk>null</jk> (written as the literal <c>null</c>).
087    * @return This object.
088    */
089   public YamlWriter key(String s) {
090      keyName(s);
091      w(": ");
092      return this;
093   }
094
095   /**
096    * Writes just the key name portion (with quoting if necessary) but without any separator.
097    *
098    * @param s The key name.
099    *    <br>Can be <jk>null</jk> (written as the literal <c>null</c>).
100    * @return This object.
101    */
102   public YamlWriter keyName(String s) {
103      if (trimStrings)
104         s = trim(s);
105
106      if (s == null) {
107         w("null");
108      } else if (needsQuoting(s)) {
109         writeQuoted(s);
110      } else {
111         w(s);
112      }
113      return this;
114   }
115
116   /**
117    * Writes a YAML scalar text value.
118    *
119    * <p>
120    * If null, writes the literal <c>null</c>. If the string needs quoting (is empty,
121    * looks like a boolean/null/number, contains special characters, starts/ends with whitespace,
122    * or contains newlines), it will be double-quoted with proper YAML escaping.
123    *
124    * @param s The string value.
125    *    <br>Can be <jk>null</jk> (written as the literal <c>null</c>).
126    * @return This object.
127    */
128   @SuppressWarnings({
129      "java:S4144" // Identical to keyName by design; distinct semantic role (value vs key quoting)
130   })
131   public YamlWriter textValue(String s) {
132      if (trimStrings)
133         s = trim(s);
134
135      if (s == null) {
136         w("null");
137      } else if (needsQuoting(s)) {
138         writeQuoted(s);
139      } else {
140         w(s);
141      }
142      return this;
143   }
144
145   /**
146    * Appends a URI value to the output.
147    *
148    * <p>
149    * Resolves the URI and writes it as a double-quoted YAML string.
150    *
151    * @param uri The URI to append to the output.
152    *    <br>Can be <jk>null</jk>.  The effect is implementation-dependent — see {@link UriResolver#resolve(Object)}.
153    * @return This object.
154    */
155   public YamlWriter uriValue(Object uri) {
156      writeQuoted(uriResolver.resolve(uri));
157      return this;
158   }
159
160   /**
161    * Writes YAML block indentation.
162    *
163    * <p>
164    * Writes a newline followed by {@code depth * 2} spaces of indentation.
165    *
166    * @param depth The indentation depth.
167    * @return This object.
168    */
169   public YamlWriter yamlIndent(int depth) {
170      w('\n');
171      for (var i = 0; i < depth * 2; i++)
172         w(' ');
173      return this;
174   }
175
176   /**
177    * Writes a YAML list entry prefix.
178    *
179    * <p>
180    * Writes a newline followed by {@code depth * 2} spaces and a dash-space (<js>"- "</js>) prefix.
181    *
182    * @param depth The indentation depth.
183    * @return This object.
184    */
185   public YamlWriter listEntry(int depth) {
186      w('\n');
187      for (var i = 0; i < depth * 2; i++)
188         w(' ');
189      w("- ");
190      return this;
191   }
192
193   /**
194    * Returns <jk>true</jk> if the specified string needs double-quoting in YAML.
195    *
196    * @param s The string to check.
197    * @return <jk>true</jk> if the string needs quoting.
198    */
199   @SuppressWarnings({
200      "java:S3776" // Cognitive complexity acceptable for YAML quoting checks
201   })
202   private static boolean needsQuoting(String s) {
203      if (s.isEmpty())
204         return true;
205
206      if (Character.isWhitespace(s.charAt(0)) || Character.isWhitespace(s.charAt(s.length() - 1)))
207         return true;
208
209      if (reservedWords.contains(s))
210         return true;
211
212      if (looksLikeNumber(s))
213         return true;
214
215      for (var i = 0; i < s.length(); i++) {
216         var c = s.charAt(i);
217         if (c == '\n' || c == '\r')
218            return true;
219         if (specialChars.contains(c))
220            return true;
221         if (escapedChars.contains(c))
222            return true;
223      }
224
225      return false;
226   }
227
228   /**
229    * Returns <jk>true</jk> if the string looks like a YAML number (integer, float, hex, or octal).
230    */
231   private static boolean looksLikeNumber(String s) {
232      if (s.isEmpty())
233         return false;
234      var c0 = s.charAt(0);
235      if (c0 == '+' || c0 == '-')
236         return s.length() > 1 && isDigitOrDot(s.charAt(1));
237      if (c0 == '.')
238         return s.length() > 1 && Character.isDigit(s.charAt(1));
239      if (c0 == '0' && s.length() > 1) {
240         var c1 = s.charAt(1);
241         if (c1 == 'x' || c1 == 'X' || c1 == 'o' || c1 == 'O')
242            return true;
243      }
244      return Character.isDigit(c0);
245   }
246
247   private static boolean isDigitOrDot(char c) {
248      return Character.isDigit(c) || c == '.';
249   }
250
251   /**
252    * Writes a double-quoted YAML string with proper escaping.
253    */
254   private void writeQuoted(String s) {
255      w('"');
256      if (s != null) {
257         for (var i = 0; i < s.length(); i++) {
258            var c = s.charAt(i);
259            if (escapedChars.contains(c)) {
260               switch (c) {
261                  case '\\': w('\\').w('\\'); break;
262                  case '"':  w('\\').w('"'); break;
263                  case '\n': w('\\').w('n'); break;
264                  case '\t': w('\\').w('t'); break;
265                  case '\r': w('\\').w('r'); break;
266                  case '\0': w('\\').w('0'); break;
267                  case '\u0007': w('\\').w('a'); break;
268                  case '\b': w('\\').w('b'); break;
269                  case '\f': w('\\').w('f'); break;
270                  case '\u001b': w('\\').w('e'); break;
271                  default: w(c);
272               }
273            } else {
274               w(c);
275            }
276         }
277      }
278      w('"');
279   }
280}