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.csv;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023import org.apache.juneau.marshall.parser.*;
024
025/**
026 * Parses CSV cell inline notation: {@code {key:val;key2:val2}} and {@code [val1;val2;val3]}.
027 *
028 * <p>
029 * Grammar (CSV-specific, semicolon-separated):
030 * <ul>
031 *   <li>Object: {@code \{ (key ':' value (';' key ':' value)*)? \}}
032 *   <li>Array: {@code \[ (value (';' value)*)? \]}
033 *   <li>Values: object | array | quoted string | number | identifier | null
034 * </ul>
035 *
036 * <h5 class='section'>Notes:</h5><ul>
037 *    <li class='note'>Reserved characters: {@code ; : \{ \} [ ] " \}
038 * </ul>
039 */
040public final class CsvCellParser {
041
042   private final String input;
043   private final String nullMarker;
044   private int pos;
045   private final int length;
046
047   private CsvCellParser(String input, String nullMarker) {
048      this.input = input != null ? input.trim() : "";
049      this.nullMarker = or(nullMarker, "null");
050      this.length = this.input.length();
051   }
052
053   /**
054    * Parses a cell value string into a Map, List, or scalar.
055    *
056    * @param cell The cell string (e.g. {@code {a:1;b:2}} or {@code [1;2;3]}).
057    *    <br>Can be <jk>null</jk> (returns <jk>null</jk>).
058    * @param nullMarker The string that denotes null (e.g. {@code <NULL>} or {@code null}).
059    *    <br>Can be <jk>null</jk> (defaults to {@code null}).
060    * @return Parsed object: Map, List, String, Number, Boolean, or null.
061    * @throws ParseException If the input is invalid.
062    */
063   public static Object read(String cell, String nullMarker) throws ParseException {
064      if (ie(cell))
065         return null;
066      var p = new CsvCellParser(cell, nullMarker);
067      try {
068         return p.readValue();
069      } catch (Exception e) {
070         throw new ParseException(e, "Invalid CSV cell notation at position %s: '%s'", p.pos, cell);
071      }
072   }
073
074   private void skipWhitespace() {
075      while (pos < length && Character.isWhitespace(input.charAt(pos)))
076         pos++;
077   }
078
079   private boolean hasMore() {
080      skipWhitespace();
081      return pos < length;
082   }
083
084   private char peek() {
085      return pos < length ? input.charAt(pos) : '\0';
086   }
087
088   private char consume() {
089      return pos < length ? input.charAt(pos++) : '\0';
090   }
091
092   private Object readValue() throws ParseException {
093      skipWhitespace();
094      if (pos >= length)
095         return null;
096      var c = peek();
097      if (c == '{')
098         return readObject();
099      if (c == '[')
100         return readArray();
101      if (c == '"')
102         return readQuotedString();
103      return readSimpleValue();
104   }
105
106   private Map<String,Object> readObject() throws ParseException {
107      if (consume() != '{')
108         throw new ParseException("Expected opening brace");
109      var m = new LinkedHashMap<String,Object>();
110      skipWhitespace();
111      if (peek() == '}') {
112         consume();
113         return m;
114      }
115      while (hasMore()) {
116         var key = readKey();
117         skipWhitespace();
118         if (consume() != ':')
119            throw new ParseException("Expected ':' after key at position " + pos);
120         var value = readValue();
121         m.put(key, value);
122         skipWhitespace();
123         var c = peek();
124         if (c == '}') {
125            consume();
126            break;
127         }
128         if (c != ';')
129            throw new ParseException("Expected ';' or '}' at position " + pos);
130         consume(); // skip ';'
131      }
132      return m;
133   }
134
135   private String readKey() throws ParseException {
136      skipWhitespace();
137      if (pos >= length)
138         throw new ParseException("Unexpected end of input in key");
139      if (peek() == '"')
140         return readQuotedString();
141      return readIdentifier();
142   }
143
144   private List<Object> readArray() throws ParseException {
145      if (consume() != '[')
146         throw new ParseException("Expected opening bracket");
147      var list = l();
148      skipWhitespace();
149      if (peek() == ']') {
150         consume();
151         return list;
152      }
153      while (hasMore()) {
154         list.add(readValue());
155         skipWhitespace();
156         var c = peek();
157         if (c == ']') {
158            consume();
159            break;
160         }
161         if (c != ';')
162            throw new ParseException("Expected ';' or ']' at position " + pos);
163         consume(); // skip ';'
164      }
165      return list;
166   }
167
168   private String readQuotedString() throws ParseException {
169      if (consume() != '"')
170         throw new ParseException("Expected quote");
171      var sb = new StringBuilder();
172      while (pos < length) {
173         var c = consume();
174         if (c == '"')
175            return sb.toString();
176         if (c == '\\' && pos < length)
177            sb.append(consume()); // escaped char
178         else
179            sb.append(c);
180      }
181      throw new ParseException("Unterminated quoted string");
182   }
183
184   private String readIdentifier() {
185      var start = pos;
186      while (pos < length) {
187         var c = input.charAt(pos);
188         if (c == ';' || c == ':' || c == '{' || c == '}' || c == '[' || c == ']' || c == '"' || Character.isWhitespace(c))
189            break;
190         pos++;
191      }
192      return input.substring(start, pos);
193   }
194
195   private Object readSimpleValue() throws ParseException {
196      skipWhitespace();
197      if (pos >= length || peek() == ';' || peek() == '}' || peek() == ']')
198         return "";
199      var id = readIdentifier();
200      if (id.isEmpty())
201         throw new ParseException("Expected value at position " + pos);
202      if (eqic(id, nullMarker))
203         return null;
204      if (eq(id, "true"))
205         return Boolean.TRUE;
206      if (eq(id, "false"))
207         return Boolean.FALSE;
208      // Try number
209      try {
210         if (id.contains(".")) {
211            var d = Double.parseDouble(id);
212            if (d == (long) d)
213               return Long.valueOf((long) d);
214            return Double.valueOf(d);
215         }
216         return Long.valueOf(id);
217      } catch (@SuppressWarnings("unused") NumberFormatException e) {
218         // Not a number, treat as string
219         return id;
220      }
221   }
222}