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.hjson;
018
019import static org.apache.juneau.commons.utils.ObjectUtils.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.io.*;
023import java.util.*;
024
025import org.apache.juneau.commons.bean.*;
026import org.apache.juneau.commons.reflect.*;
027import org.apache.juneau.marshall.*;
028import org.apache.juneau.marshall.collections.*;
029import org.apache.juneau.marshall.parser.*;
030import org.apache.juneau.marshall.stream.*;
031
032/**
033 * Session for parsing Hjson format into POJOs.
034 */
035@SuppressWarnings({
036   "java:S110", // Inheritance depth acceptable for parser session hierarchy
037   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
038   "java:S3776", // Cognitive complexity acceptable for Hjson parse logic
039   "java:S6541", // Acceptable for session implementation
040   "resource", // Closeable resources are owned by the caller's parser session; Eclipse JDT @Owning warning is by design.
041   "unchecked" // (T) casts in doRead for generic return type
042})
043public class HjsonParserSession extends ReaderParserSession implements RecordReadable {
044
045   /**
046    * Builder for Hjson parser session.
047    */
048   public static class Builder extends ReaderParserSession.Builder<Builder> {
049
050      protected Builder(HjsonParser ctx) {
051         super(reqnn("ctx", ctx));
052      }
053
054      @Override
055      public HjsonParserSession build() {
056         return new HjsonParserSession(this);
057      }
058   }
059
060   /**
061    * Creates a session builder.
062    *
063    * @param ctx The parser context.
064    *    <br>Cannot be <jk>null</jk>.
065    * @return The builder.
066    */
067   public static Builder create(HjsonParser ctx) {
068      return new Builder(reqnn("ctx", ctx));
069   }
070
071   protected HjsonParserSession(Builder builder) {
072      super(builder);
073   }
074
075   @Override /* RecordReadable */
076   public RecordReader readRecords(Object input) throws IOException {
077      return RecordAdapter.reader(this, input);
078   }
079
080   @Override /* RecordReadable */
081   public boolean isRecordStreaming() {
082      return false;
083   }
084
085   @Override
086   protected <T> T doRead(ParserPipe pipe, ClassMeta<T> type) throws IOException, ParseException, ExecutableException {
087      String inputStr;
088      try {
089         inputStr = pipe.asString();
090      } catch (IOException e) {
091         throw new ParseException(e);
092      }
093      if (inputStr == null)
094         return null;
095      var trimmed = inputStr.trim();
096      if (trimmed.isEmpty())
097         return null;
098      var tokenizer = new HjsonTokenizer(new StringReader(inputStr));
099      tokenizer.skipWhitespaceAndComments();
100      var peek = tokenizer.peek();
101      if (peek.type() == HjsonTokenizer.TokenType.EOF)
102         return null;
103      var peekType = peek.type();
104      // LBRACE/LBRACKET -> readValue (object/array)
105      if (peekType == HjsonTokenizer.TokenType.LBRACE || peekType == HjsonTokenizer.TokenType.LBRACKET) {
106         return (T) readValue(tokenizer, type);
107      }
108      // QUOTELESS/STRING: distinguish braceless root "key: value" from lone value "string"
109      if (peekType == HjsonTokenizer.TokenType.QUOTELESS || peekType == HjsonTokenizer.TokenType.STRING) {
110         var firstToken = tokenizer.read();
111         var firstVal = firstToken.stringValue();
112         tokenizer.skipWhitespaceAndComments();
113         var nextType = tokenizer.peek().type();
114         // Target is String/number/boolean/etc - always lone value (not key:value map)
115         if (!type.isMap() && !type.isBean())
116            return (T) coerceMemberValue(firstVal, type);
117         // Target is Map/Bean: COLON means key:value (braceless root)
118         if (nextType == HjsonTokenizer.TokenType.COLON)
119            return (T) readRootBraceless(tokenizer, type, firstVal);
120         // No COLON - lone value (e.g. "1.0" or "true" tokenized as QUOTELESS)
121         return (T) coerceMemberValue(firstVal, type);
122      }
123      // NUMBER, TRUE, FALSE, NULL, etc - lone value; use (T) not type.cast (cast fails for boxed->primitive)
124      return (T) coerceMemberValue(readValue(tokenizer, type), type);
125   }
126
127   private Object readRootBraceless(HjsonTokenizer t, ClassMeta<?> type, String firstKey) throws IOException, ParseException, ExecutableException {
128      var result = newGenericMap();
129      // Same per-property type threading as readObject so braceless-root beans get typed-map keys.
130      var beanMeta = (type != null && type.isBean()) ? type.getBeanMeta() : null;
131      if (t.read().type() != HjsonTokenizer.TokenType.COLON)
132         throw new ParseException(this, "Expected : after key at line %s", t.getLine());
133      t.skipWhitespaceAndComments();
134      var value = readValue(t, propertyType(beanMeta, firstKey, type));
135      result.put(firstKey, value);
136      t.skipWhitespaceAndComments();
137      if (t.peek().type() == HjsonTokenizer.TokenType.NEWLINE)
138         t.read();
139      while (t.peek().type() != HjsonTokenizer.TokenType.EOF) {
140         var key = readKey(t);
141         t.skipWhitespaceAndComments();
142         if (t.read().type() != HjsonTokenizer.TokenType.COLON)
143            throw new ParseException(this, "Expected : after key at line %s", t.getLine());
144         t.skipWhitespaceAndComments();
145         value = readValue(t, propertyType(beanMeta, key, type));
146         result.put(key, value);
147         t.skipWhitespaceAndComments();
148         if (t.peek().type() == HjsonTokenizer.TokenType.NEWLINE)
149            t.read();
150      }
151      return convertToBean(result, type);
152   }
153
154   private Object readValue(HjsonTokenizer t, ClassMeta<?> type) throws IOException, ParseException, ExecutableException {
155      var tok = t.read();
156      Object raw = switch (tok.type()) {
157         case STRING, QUOTELESS, MULTILINE -> isTrimStrings() ? tok.stringValue().trim() : tok.stringValue();
158         case NUMBER -> tok.numberValue();
159         case TRUE -> true;
160         case FALSE -> false;
161         case NULL -> null;
162         case LBRACE -> readObject(t, type);
163         case LBRACKET -> readArray(t, type);
164         case EOF -> null;
165         default -> throw new ParseException(this, "Unexpected token %s at line %s", tok.type(), t.getLine());
166      };
167      if (raw == null)
168         return null;
169      if (tok.type() == HjsonTokenizer.TokenType.LBRACE || tok.type() == HjsonTokenizer.TokenType.LBRACKET)
170         return raw;
171      return coerceMemberValue(raw, type);
172   }
173
174   private Object readObject(HjsonTokenizer t, ClassMeta<?> type) throws IOException, ParseException, ExecutableException {
175      var result = newGenericMap();
176      // When parsing a typed Bean, look up per-property ClassMetas so that nested Map<K,V> properties
177      // see their declared key/value types and the key coercion in the inner readObject can fire
178      // (matches JsonParserSession.readIntoBeanMap2 / readIntoMap2 pattern).
179      var beanMeta = (type != null && type.isBean()) ? type.getBeanMeta() : null;
180      t.skipWhitespaceAndComments();
181      var next = t.peek();
182      if (next.type() == HjsonTokenizer.TokenType.RBRACE) {
183         t.read();
184         return convertToBean(result, type);
185      }
186      while (true) {
187         var key = readKey(t);
188         t.skipWhitespaceAndComments();
189         if (t.read().type() != HjsonTokenizer.TokenType.COLON)
190            throw new ParseException(this, "Expected : after key at line %s", t.getLine());
191         t.skipWhitespaceAndComments();
192         var valueType = propertyType(beanMeta, key, type);
193         var value = readValue(t, valueType);
194         result.put(key, value);
195         t.skipWhitespaceAndComments();
196         next = t.peek();
197         if (next.type() == HjsonTokenizer.TokenType.RBRACE) {
198            t.read();
199            break;
200         }
201         if (next.type() == HjsonTokenizer.TokenType.COMMA || next.type() == HjsonTokenizer.TokenType.NEWLINE) {
202            t.read();
203            t.skipWhitespaceAndComments();
204         }
205      }
206      return convertToBean(result, type);
207   }
208
209   /**
210    * Coerces a parsed scalar value to the expected type, honoring session-aware default swaps for
211    * {@code byte[]} targets at the collection-element / top-level dispatch sites.
212    *
213    * <p>For all other targets this is a transparent wrapper over {@link #convertToMemberType} so the
214    * existing dispatch behavior is preserved.  The narrow {@code byte[]} hook lets the configured
215    * {@link BinaryFormat}'s variant {@link org.apache.juneau.marshall.swaps.BinarySwap} unswap the wire-form
216    * string back into raw bytes — these dispatch sites don't go through
217    * {@code MarshalledPropertyPostProcessor}'s per-property swap install, so without this they fall
218    * through to {@code BasicConverter}'s default {@code String → byte[]} UTF-8 coercion.
219    */
220   private Object coerceMemberValue(Object raw, ClassMeta<?> type) throws ParseException {
221      if (raw instanceof String raw2 && type != null && type.inner() == byte[].class) {
222         var swap = type.getSwap(this);
223         if (swap != null)
224            return unswap(swap, raw2, type);
225      }
226      return convertToMemberType(null, raw, type);
227   }
228
229   /**
230    * Resolves the {@link ClassMeta} to parse a bean-property value against, falling back to {@code object()}
231    * when the enclosing type isn't a bean or the key isn't a known property.  Threading the property type
232    * lets typed-map properties (e.g. {@code Map<TestEnum,String>}) coerce their keys via the converter's
233    * {@code Map → Map} path.
234    *
235    * <p>
236    * Also surfaces {@link ClassMeta#isCollectionOrArray()} property types so the
237    * collection-element dispatch in {@link #readArray} threads the parent's element type into recursion —
238    * that's where {@link #coerceMemberValue} reads the {@code byte[]} target and invokes the configured
239    * {@link org.apache.juneau.marshall.swaps.BinarySwap}'s unswap on the wire-form string.  Other property shapes
240    * (Optional, primitive, bean) still pass through as {@code object()} so this parser's lazy-typed
241    * conversion path keeps working unchanged.
242    */
243   private ClassMeta<?> propertyType(BeanMeta<?> beanMeta, String key, ClassMeta<?> enclosingType) {
244      if (beanMeta != null && key != null && !key.equals(getBeanTypePropertyName(enclosingType))) {
245         var pMeta = beanMeta.getPropertyMeta(key);
246         if (pMeta != null) {
247            var cm = (ClassMeta<?>) pMeta.getBeanInfo();
248            if (cm != null && (cm.isMap() || cm.isCollectionOrArray()))
249               return cm;
250         }
251      }
252      return object();
253   }
254
255   private String readKey(HjsonTokenizer t) throws IOException {
256      var tok = t.read();
257      return switch (tok.type()) {
258         case STRING, QUOTELESS, MULTILINE -> isTrimStrings() ? tok.stringValue().trim() : tok.stringValue();
259         case NULL -> null;  // null keyword used as key -> preserve as null (matches JSON parser behavior)
260         case TRUE -> "true";
261         case FALSE -> "false";
262         case NUMBER -> tok.numberValue().toString();
263         default -> throw new ParseException(this, "Expected key at line %s", t.getLine());
264      };
265   }
266
267   private Object readArray(HjsonTokenizer t, ClassMeta<?> type) throws IOException, ParseException, ExecutableException {
268      if (type == null)
269         type = object();
270      var result = new JsonList();
271      t.skipWhitespaceAndComments();
272      var next = t.peek();
273      if (next.type() == HjsonTokenizer.TokenType.RBRACKET) {
274         t.read();
275         return convertToCollection(result, type);
276      }
277      while (true) {
278         var eType = coalesce(type.getElementType(), object());
279         result.add(readValue(t, eType));
280         t.skipWhitespaceAndComments();
281         next = t.peek();
282         if (next.type() == HjsonTokenizer.TokenType.RBRACKET) {
283            t.read();
284            break;
285         }
286         if (next.type() == HjsonTokenizer.TokenType.COMMA || next.type() == HjsonTokenizer.TokenType.NEWLINE) {
287            t.read();
288            t.skipWhitespaceAndComments();
289         }
290      }
291      return convertToCollection(result, type);
292   }
293
294   private Object convertToBean(MarshalledMap map, ClassMeta<?> type) throws ExecutableException, ParseException {
295      if (type == null)
296         type = object();
297      var casted = cast(map, null, type);
298      if (casted != map)
299         return casted;
300      if (!type.isObject()) {
301         // If target type has a swap (e.g. surrogate D1→D2), parse JsonMap as swap class first, then unswap.
302         var swap = type.getSwap(this);
303         if (nn(swap)) {
304            var sType = swap.getSwapClassMeta(this);
305            var swapped = convertToMemberType(null, map, sType);
306            return unswap(swap, swapped, type);
307         }
308         var result = convertToMemberType(null, map, type);
309         if (type.isBean() && result != null)
310            injectAnnotations(map, result);
311         return result;
312      }
313      return map;
314   }
315
316   private void injectAnnotations(MarshalledMap map, Object bean) throws ExecutableException {
317      var bm = toBeanMap(bean);
318      for (var entry : map.entrySet()) {
319         var key = entry.getKey();
320         var pm = bm.getPropertyMeta(key);
321         var val = (pm == null) ? null : getBeanValueSafely(bm, key);
322         if (pm == null || val == null)
323            continue;
324         var cm = (ClassMeta<?>) pm.getBeanInfo();
325         if (cm.getNameProperty() != null)
326            setName(cm, val, key);
327         injectParentAnnotations(cm, val, entry.getValue(), bean);
328      }
329   }
330
331   /*
332    * Recursively injects @ParentProperty (and @NameProperty on map values / nested beans) into val.
333    * Intermediate collections and maps are transparent: a collection/map element's parent is
334    * parentBean (the nearest enclosing bean), skipping all intermediate containers.  This keeps HJSON
335    * consistent with the streaming parsers.
336    */
337   private void injectParentAnnotations(ClassMeta<?> cm, Object val, Object node, Object parentBean) throws ExecutableException {
338      if (val == null || cm == null)
339         return;
340      if (cm.getParentProperty() != null)
341         setParent(cm, val, parentBean);
342      if (cm.isCollectionOrArray()) {
343         var et = cm.getElementType();
344         if (et == null || et.isObject())
345            return;
346         var nodeList = node instanceof List<?> nl ? nl : null;
347         var i = 0;
348         for (var element : toIterable(val)) {
349            injectParentAnnotations(et, element, nodeList != null && i < nodeList.size() ? nodeList.get(i) : null, parentBean);
350            i++;
351         }
352      } else if (cm.isMap() && val instanceof Map<?,?> valMap) {
353         var vt = cm.getValueType();
354         if (vt == null || vt.isObject())
355            return;
356         var nodeMap = node instanceof Map<?,?> nm ? nm : null;
357         for (Map.Entry<?,?> e : valMap.entrySet()) {
358            if (vt.getNameProperty() != null)
359               setName(vt, e.getValue(), e.getKey());
360            injectParentAnnotations(vt, e.getValue(), nodeMap != null ? nodeMap.get(e.getKey()) : null, parentBean);
361         }
362      } else if (cm.isBean() && !(val instanceof Map) && node instanceof MarshalledMap nodeMap) {
363         injectAnnotations(nodeMap, val);
364      }
365   }
366
367   private static Iterable<?> toIterable(Object val) {
368      if (val instanceof Collection<?> c)
369         return c;
370      if (val instanceof Object[] a)
371         return Arrays.asList(a);
372      return List.of();
373   }
374
375   private static Object getBeanValueSafely(BeanMap<?> bm, String key) {
376      try {
377         return bm.get(key);
378      } catch (@SuppressWarnings("unused") Exception e) {
379         return null;
380      }
381   }
382
383   private Object convertToCollection(MarshalledList list, ClassMeta<?> type) throws ExecutableException {
384      if (type == null)
385         type = object();
386      var eType = coalesce(type.getElementType(), object());
387      var converted = newGenericList();
388      for (var item : list)
389         converted.add(convertToMemberType(null, item, eType));
390      // For array types, convert to the appropriate array
391      if (type.inner() != null && type.inner().isArray())
392         return toArray(type, converted);
393      // For specific collection subtypes (e.g. Vector, LinkedList), convert via MarshallingSession
394      if (!type.isObject())
395         return convertToMemberType(null, converted, type);
396      return converted;
397   }
398}