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.commons.beanquery;
018
019import static org.apache.juneau.commons.lang.StateEnum.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.util.*;
023
024/**
025 * The clause grammar shared by the one {@link BeanQuery#getSearch() search} string and the one
026 * {@link BeanQuery#getOpts() opts} string (design §5.3).
027 *
028 * <p>
029 * A string is a list of {@code key=value} clauses:
030 * <ul>
031 *    <li>Top-level commas (outside parentheses) separate clauses.  A comma <b>inside</b> parentheses is protected (so an
032 *       {@code $in(OPEN,CLOSED)} value survives intact).
033 *    <li>The <b>first unescaped</b> {@code =} in a clause separates the key from the value; the value keeps any further
034 *       {@code =} characters verbatim.
035 *    <li>Backslash escapes decode to literals: {@code \,} &rarr; comma, {@code \=} &rarr; equals, {@code \\} &rarr;
036 *       backslash, {@code \$} &rarr; dollar.  Any other backslash is left as-is.
037 * </ul>
038 *
039 * <p>
040 * For {@code search}, the key is a column name and the value is a raw {@code $}-expression handed to
041 * {@link SearchExpressionParser}.  For {@code opts}, the key is an option name and the value is a plain string.  A
042 * session decodes these strings when the query runs; the {@link BeanQuery} bean itself never parses them.
043 *
044 * @since 10.0.0
045 */
046public final class ClauseParser {
047
048   private ClauseParser() {}
049
050   /**
051    * Parses a search-or-opts string into an ordered {@code key}&rarr;{@code value} map.
052    *
053    * <p>
054    * Keys are stripped of surrounding whitespace; values are decoded but otherwise kept verbatim.  A blank input, or a
055    * <jk>null</jk> input, yields an empty map.  A later clause with a duplicate key overwrites an earlier one.
056    *
057    * @param s The raw string (a {@code search} or {@code opts} value).  Can be <jk>null</jk>.
058    * @return A new, mutable, insertion-ordered map (never <jk>null</jk>).
059    */
060   @SuppressWarnings({
061      "java:S3776", // Cognitive complexity acceptable for parser state machine
062      "java:S6541" // Brain method acceptable for parser state machine
063   })
064   public static Map<String,String> parse(String s) {
065      var m = new LinkedHashMap<String,String>();
066      if (ib(s))
067         return m;
068
069      // S1: Reading key (looking for unescaped '=' or top-level ',' or end).
070      // S2: Saw '\', next char is a key literal (\, / \= / \\ collapse; others keep the backslash).
071      // S3: Reading value (looking for top-level ',' or end); '(' / ')' adjust depth.
072      // S4: Saw '\', next char is a value literal.
073
074      var state = S1;
075      var key = new StringBuilder();
076      var value = new StringBuilder();
077      var depth = 0;
078      var len = s.length();
079
080      for (var i = 0; i <= len; i++) {
081         var end = i == len;
082         var c = end ? ',' : s.charAt(i); // Synthetic comma flushes the final clause.
083
084         if (state == S1) {
085            if (end || (c == ',' && depth == 0)) {
086               putClause(m, key, value);
087               key.setLength(0);
088               value.setLength(0);
089               depth = 0;
090            } else if (c == '\\') {
091               state = S2;
092            } else if (c == '=') {
093               state = S3;
094            } else {
095               if (c == '(')
096                  depth++;
097               else if (c == ')' && depth > 0)
098                  depth--;
099               key.append(c);
100            }
101         } else if (state == S2) {
102            if (end) {
103               key.append('\\');
104               putClause(m, key, value);
105            } else {
106               appendUnescaped(key, c);
107               state = S1;
108            }
109         } else if (state == S3) { // NOSONAR - State check necessary for state machine
110            if (end || (c == ',' && depth == 0)) {
111               putClause(m, key, value);
112               key.setLength(0);
113               value.setLength(0);
114               depth = 0;
115               state = S1;
116            } else if (c == '\\') {
117               state = S4;
118            } else {
119               if (c == '(')
120                  depth++;
121               else if (c == ')' && depth > 0)
122                  depth--;
123               value.append(c);
124            }
125         } else {  // S4 — the only remaining state.
126            if (end) {
127               value.append('\\');
128               putClause(m, key, value);
129            } else {
130               appendUnescaped(value, c);
131               state = S3;
132            }
133         }
134      }
135
136      return m;
137   }
138
139   /**
140    * Escapes a single key or value so it can be spliced into a clause string without changing its meaning.
141    *
142    * <p>
143    * Backslash, comma, and equals become {@code \\}, {@code \,}, and {@code \=} respectively.  A <b>leading</b>
144    * {@code $} additionally becomes {@code \$}, so an escaped column name can never be mistaken for a
145    * {@code $or(}/{@code $and(}/{@code $not(} search-grammar group prefix (design §4).  Note that a raw
146    * {@code $}-expression value must <b>not</b> be escaped &mdash; its commas live inside parentheses and its
147    * structure is the {@code $}-language's own, not this grammar's.
148    *
149    * @param s The raw key or value.  Can be <jk>null</jk>.
150    * @return The escaped text, or <jk>null</jk> if {@code s} is <jk>null</jk>.
151    */
152   public static String escape(String s) {
153      if (s == null)
154         return null;
155      var sb = new StringBuilder(s.length() + 4);
156      for (var i = 0; i < s.length(); i++) {
157         var c = s.charAt(i);
158         if ((i == 0 && c == '$') || c == '\\' || c == ',' || c == '=')
159            sb.append('\\');
160         sb.append(c);
161      }
162      return sb.toString();
163   }
164
165   /** Emits one decoded clause when the key is non-blank after strip. */
166   private static void putClause(Map<String,String> m, StringBuilder key, StringBuilder value) {
167      var k = key.toString().strip();
168      if (! k.isEmpty())
169         m.put(k, value.toString());
170   }
171
172   /**
173    * Appends one escape sequence into {@code buf}: {@code \,}/{@code \=}/{@code \\}/{@code \$} collapse to the
174    * literal; any other following character keeps the backslash.
175    */
176   private static void appendUnescaped(StringBuilder buf, char c) {
177      if (c == ',' || c == '=' || c == '\\' || c == '$')
178         buf.append(c);
179      else
180         buf.append('\\').append(c);
181   }
182}