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 single public parser for the {@code $}-search language (design §5.1).  Engines call it; a {@link BeanQuery}
026 * itself carries only the raw strings.
027 *
028 * <h5 class='section'>Grammar</h5>
029 * <p>
030 * A column's expression is one of:
031 * <ul>
032 *    <li>A <b>pattern</b> &mdash; a bare token (case-insensitive substring / prefix / exact per the column type),
033 *       optionally with {@code *} wildcards, or a value such as {@code $100} whose leading {@code $} is not a known
034 *       operator invocation.  Patterns are {@link SearchExpression#isLiteral() literal} nodes.  This also covers a
035 *       relative-duration token such as {@code -24h} or {@code +7d} ({@code [+-]<n>(ms|s|m|h|d)}) or a signed ISO-8601 duration such as {@code -PT24H}: the parser applies
036 *       no special grammar to it, since it is already an ordinary pattern; a TIMESTAMP or duration column's typed
037 *       conversion (not this parser) is what resolves it relative to request time.
038 *    <li>An <b>operator</b> &mdash; {@code $name(arg,...)} where {@code $name} is in the supplied
039 *       {@link SearchOperatorSet}.  Leaf-operator arguments are literals; combinator ({@code $and}/{@code $or}/
040 *       {@code $not}) arguments are themselves expressions.  Columns combine with {@code and}; {@code $or}/{@code $and}/
041 *       {@code $not} combine within one column.
042 * </ul>
043 *
044 * <h5 class='section'>Literal dollar</h5>
045 * <p>
046 * {@code $$} is a literal {@code $} everywhere.  A token that starts with {@code $$} is always a pattern (never an
047 * operator), and every {@code $$} inside a literal decodes to a single {@code $}.  So {@code $$eq(x)} is the literal
048 * text {@code $eq(x)}, while {@code $eq(x)} is the operator.
049 *
050 * <h5 class='section'>Quoting</h5>
051 * <p>
052 * A quoted literal argument (design §5.1) may contain its own delimiter by doubling it &mdash; {@code ""} inside a
053 * double-quoted value, or {@code ''} inside a single-quoted value, decodes to one literal quote character of that
054 * same kind.  So {@code $eq("a""b")} is the literal text {@code a"b}.  This doubling decode is independent of, and
055 * composes with, the {@code $$} &rarr; {@code $} decoding above.
056 *
057 * <h5 class='section'>Errors</h5>
058 * <p>
059 * An unknown {@code $name(...)}, a malformed expression (unclosed/unbalanced parentheses, junk after the close paren,
060 * a stray comma, a wrong argument count), or an empty expression throws {@link BeanQuerySyntaxException}.  There is no
061 * "incomplete draft" state on the server: a request-facing adapter catches the exception and surfaces the message.
062 *
063 * <h5 class='section'>Example:</h5>
064 * <p class='bjava'>
065 *    <jk>try</jk> {
066 *       SearchExpression <jv>node</jv> = SearchExpressionParser.<jsm>parse</jsm>(<js>"$gt(10)"</js>, SearchOperatorSet.<jsm>standard</jsm>());
067 *       <jc>// node.isFunction() == true, node.name() == "$gt", node.literalArgs() == ["10"]</jc>
068 *    } <jk>catch</jk> (BeanQuerySyntaxException <jv>e</jv>) {
069 *       <jc>// e.code() names the specific grammar problem, e.g. Code.UNKNOWN_OPERATOR or Code.UNBALANCED</jc>
070 *    }
071 * </p>
072 *
073 * @since 10.0.0
074 */
075public final class SearchExpressionParser {
076
077   /** No instances. */
078   private SearchExpressionParser() {}
079
080   /**
081    * Parses a raw column-search expression.
082    *
083    * @param raw The raw expression string (a pattern or {@code $name(...)}).  Must not be <jk>null</jk> or blank.
084    * @param operators The operator set that defines which {@code $}-names are recognized.  Must not be <jk>null</jk>.
085    * @return The parsed expression, never <jk>null</jk>.
086    * @throws BeanQuerySyntaxException If the expression is empty, names an unknown operator, or is malformed.
087    */
088   public static SearchExpression parse(String raw, SearchOperatorSet operators) {
089      reqnn("operators", operators);
090      if (ib(raw))
091         throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.EMPTY_EXPRESSION, "Empty search expression.");
092      return parseExpr(raw.strip(), operators);
093   }
094
095   /** Parses one (already trimmed, non-empty) token into a node. */
096   private static SearchExpression parseExpr(String s, SearchOperatorSet operators) {
097      if (! s.startsWith("$$") && s.startsWith("$") && s.length() >= 2 && Character.isLetter(s.charAt(1)))
098         return parseFunc(s, operators);
099      if (isUnterminatedQuote(s))
100         throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNTERMINATED_QUOTE, "Unterminated quoted value in search expression: '%s'", s);
101      return SearchExpression.literal(decodeLiteral(s), isQuotedToken(s));
102   }
103
104   /** Whether {@code s} is delimited by a matching pair of double or single quotes. */
105   private static boolean isQuotedToken(String s) {
106      if (s.length() < 2)
107         return false;
108      var first = s.charAt(0);
109      return (first == '"' || first == '\'') && s.charAt(s.length() - 1) == first;
110   }
111
112   /** Whether {@code s} opens a quote ({@code "} or {@code '} as its first character) that its own last character does not close. */
113   private static boolean isUnterminatedQuote(String s) {
114      var first = s.charAt(0);
115      return (first == '"' || first == '\'') && ! isQuotedToken(s);
116   }
117
118   /**
119    * Parses a {@code $name(args...)} token that must span the entire string {@code s}.
120    *
121    * <p>
122    * After validating the operator name, a state machine walks the argument list once: top-level commas separate
123    * arguments, quotes protect commas/parens, and the matching {@code )} ends the call.  Nested expressions are then
124    * parsed recursively (same shape as the JSON parser's nested object/array dispatch).
125    */
126   @SuppressWarnings({
127      "java:S3776", // Cognitive complexity acceptable for parser state machine
128      "java:S6541" // Brain method acceptable for parser state machine
129   })
130   private static SearchExpression parseFunc(String s, SearchOperatorSet operators) {
131      var open = s.indexOf('(');
132      if (open < 0)
133         throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.MALFORMED_OPERATOR, "Malformed search expression (operator name without arguments): '%s'", s);
134      var name = s.substring(0, open);
135      if (! isValidName(name))
136         throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.INVALID_OPERATOR_NAME, "Invalid operator name: '%s'", name);
137      var op = operators.get(name);
138      if (op == null)
139         throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNKNOWN_OPERATOR, "Unknown search operator: '%s'", name);
140
141      // S1: Outside quotes, tracking paren depth (looking for top-level ',' or matching ')').
142      // S2: Inside a double-quoted argument fragment.
143      // S3: Inside a single-quoted argument fragment.
144
145      var state = S1;
146      var depth = 0;
147      var argStart = open + 1;
148      var rawArgs = new ArrayList<String>();
149
150      for (var i = open; i < s.length(); i++) {
151         var c = s.charAt(i);
152         if (state == S1) {
153            if (c == '"') {
154               state = S2;
155            } else if (c == '\'') {
156               state = S3;
157            } else if (c == '(') {
158               depth++;
159            } else if (c == ')' && --depth == 0) {
160               if (argStart != i || ! rawArgs.isEmpty())  // Otherwise $blank() — zero arguments.
161                  addPart(s, rawArgs, s.substring(argStart, i));
162               if (inb(s.substring(i + 1)))
163                  throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.TRAILING_TEXT, "Unexpected text after ')' in search expression: '%s'", s);
164               var args = rawArgs.stream().map(part -> parseExpr(part.strip(), operators)).toList();
165               if (! op.acceptsArgCount(args.size()))
166                  throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.BAD_ARG_COUNT,
167                     regexHint("Operator '%s' does not accept %s argument(s).", op.name()), op.name(), args.size());
168               return SearchExpression.func(op, args);
169            } else if (c == ',' && depth == 1) {
170               addPart(s, rawArgs, s.substring(argStart, i));
171               argStart = i + 1;
172            }
173         } else if (state == S2 && c == '"') {
174            state = S1;
175         } else if (state == S3 && c == '\'') {
176            state = S1;
177         }
178      }
179      if (state != S1)
180         throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNTERMINATED_QUOTE, "Unterminated quoted value in search expression: '%s'", s);
181      throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNBALANCED, regexHint("Unclosed parenthesis in search expression: '%s'", op.name()), s);
182   }
183
184   /** A valid operator name is {@code $} + a letter + zero or more letters/digits. */
185   static boolean isValidName(String name) {
186      if (name.length() < 2 || name.charAt(0) != '$' || ! Character.isLetter(name.charAt(1)))
187         return false;
188      return name.substring(2).chars().allMatch(c -> Character.isLetterOrDigit((char)c));
189   }
190
191   /** Appends the D5 quoting hint to {@code message} when {@code opName} is {@code $regex}; otherwise unchanged. */
192   private static String regexHint(String message, String opName) {
193      return "$regex".equals(opName) ? message + " Quote regex patterns that contain commas or parentheses: $regex(\"…\")." : message;
194   }
195
196   /** Appends one split argument, rejecting a blank one (a stray/trailing comma). */
197   private static void addPart(String whole, List<String> parts, String part) {
198      if (ib(part))
199         throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.EMPTY_ARGUMENT, "Empty argument in search expression: '%s'", whole);
200      parts.add(part);
201   }
202
203   /**
204    * Strips a matching pair of surrounding double or single quotes from a literal token, collapses a doubled
205    * instance of that same quote character to one literal quote (design D5 — {@code ""} or {@code ''} inside a
206    * quoted value is one literal quote character), then decodes every {@code $$} escape to a single {@code $}.
207    */
208   private static String decodeLiteral(String s) {
209      var v = s;
210      if (v.length() >= 2) {
211         var first = v.charAt(0);
212         if ((first == '"' || first == '\'') && v.charAt(v.length() - 1) == first) {
213            v = v.substring(1, v.length() - 1);
214            var q = String.valueOf(first);
215            v = v.replace(q + q, q);
216         }
217      }
218      return v.replace("$$", "$");
219   }
220}