LogicalExpressionConverter.java

/*
** Module   : LogicalExpressionConverter.java
** Abstract : Conversion of the logical expression accepted by CONTAINS operator to the
**            Reverse Polish Notation(RPN), Conjunctive Normal Form (CNF),
**            or Disjunctive Normal Form (DNF)
**
** Copyright (c) 2020-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 IAS 20201011 Created initial version.
**     IAS 20210306 Added ordering of CNF
**     IAS 20210310 Fixed ordering of CNF
**     IAS 20210315 Collect and export terms of the initial expressions
**     OM  20210423 Avoid NPE when token list is empty.
**     IAS 20211208 Fixed logical expression parsing: treat 'a b' as 'a & b'. 
** 002 SP  20240612 Improved word split logic used in Lexer.readNextToken().
**                  Added Lexer.isToBeSkipped(char) to check if a character 
**                  should be skipped when reading next token.
** 003 OM  20240801 Asterisk is a special character used as wildcard and must be processed later.
** 004 RNC 20250204 Signal that we should exit the enclosing block when encountering a wildcard error.
** 005 SP  20250311 Performance improvements. Javadoc fixes.
** 006 RNC 20250328 Improved wildcard handling in the tokenization.
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.util;

import java.io.*;
import java.util.*;
import java.util.regex.*;

/**
 * Conversion of the logical expression accepted by CONTAINS operator to the
 * Reverse Polish Notation(RPN), Conjunctive Normal Form (CNF), or Disjunctive Normal Form (DNF)
 */
public class LogicalExpressionConverter
{
   /** Regular expression pattern used to identify delimiters for splitting words. */
   private final Pattern delimiterPattern;

   /** Should matches be checked case-sensitively? */
   private final boolean caseSensitive;

   /** Logical expression to be converted. */
   private final String expr;

   /** Distinct terms found in the expression. */
   private Map<String, Term> terms = new LinkedHashMap<>();

   /**
    * Constructor.
    * 
    * @param   expr
    *          Logical expression to be converted.
    * @param   caseSensitive
    *          Should matches be checked case-sensitively?
    */
   public LogicalExpressionConverter(String expr, boolean caseSensitive)
   {
      this(expr, caseSensitive, null);
   }

   /**
    * Constructor
    * 
    * @param   expr
    *          Logical expression to be converted.
    * @param   caseSensitive
    *          Should matches be checked case-sensitively?
    * @param   codePage
    *          Character encoding used.
    */
   public LogicalExpressionConverter(String expr, boolean caseSensitive, String codePage)
   {
      this.delimiterPattern = I18nOps.getDelimiterPattern(codePage);
      this.caseSensitive = caseSensitive;
      this.expr = expr;
   }

   /**
    * Get distinct terms found in the expression.
    *
    * @return  distinct terms found in the expression
    */
   public Map<String, Term> getTerms()
   {
      return terms;
   }

   /**
    * Convert to Reverse Polish Notation (RPN).
    * 
    * @param   debug
    *          Print tokens as they are provided by lexer.
    *
    * @return  RPN representation of the expression
    */
   public List<EToken> toRPN(boolean debug)
   {
      Deque<EToken> stack = new ArrayDeque<>();
      List<EToken> rpn = new ArrayList<>();
      try (Reader reader = new StringReader(expr))
      {
         Lexer lexer = new Lexer(reader, delimiterPattern, caseSensitive);
         EToken token;
         boolean wasTerm = false;
         while ((token = lexer.token()) != null)
         {
            if (debug)
            {
               System.out.println(token);
            }
            if (token instanceof Term)
            {
               if (wasTerm)
               {
                  processOp(Op.AND, stack, rpn);
               }
               rpn.add(token);
               wasTerm = true;
            } 
            else if (token == Brackets.LPARENS)
            {
               if (wasTerm)
               {
                  processOp(Op.AND, stack, rpn);
               }
               stack.push(token);
               wasTerm = false;
            } 
            else if (token instanceof Op)
            {
               wasTerm = false;
               processOp(token, stack, rpn);
            } 
            else if (token == Brackets.RPARENS)
            {
               while (!stack.isEmpty())
               {
                  EToken top = stack.pop();
                  if (top == Brackets.LPARENS)
                  {
                     break;
                  }
                  rpn.add(top);
               }
               wasTerm = true;
            } 
            else
            {
               throw new ErrorConditionException("Invalid expression");
            }
            lexer.consume();
         }
         while (!stack.isEmpty())
         {
            EToken top = stack.pop();
            if (top instanceof Op)
            {
               rpn.add(top);
            } 
            else
            {
               throw new ErrorConditionException("Invalid expression");
            }
         }
         this.terms = lexer.terms;
      } 
      catch (IOException e)
      {
         throw new ErrorConditionException(e);
      }
      return rpn;
   }

   /**
    * Process operation token.
    *
    * @param   token
    *          Operation token.
    * @param   stack
    *          RPN stack.
    * @param   rpn
    *          RPN expression.
    */
   private void processOp(EToken token, Deque<EToken> stack, List<EToken> rpn)
   {
      while (!stack.isEmpty())
      {
         EToken top = stack.peek();
         if (token instanceof Op)
         {
            if (top instanceof Op && ((Op) token).priority >= ((Op) top).priority)
            {
               top = stack.pop();
               rpn.add(top);
            } 
            else
            {
               break;
            }
         }
      }
      stack.push(token);
   }
   
   /**
    * Convert to Conjunctive Normal Form (CNF)
    * 
    * @param   debug 
    *          Enables printing of tokens as they are provided by lexer and intermediate data.
    *
    * @return  CNF representation of the expression
    */
   public List<List<Term>> toCNF(boolean debug)
   {
      return toNormalForm(Op.OR, Op.AND, debug);
   }
   
   /**
    * Convert to Disjunctive Normal Form (DNF).
    * 
    * @param   debug
    *          Print tokens as they are provided by lexer and intermediate data.
    *
    * @return  DNF representation of the expression
    */
   public List<List<Term>> toDNF(boolean debug)
   {
      return toNormalForm(Op.AND, Op.OR, debug);
   }
   
   /**
    * Convert to Normal Form (CNF or DNF)
    * 
    * @param   op1
    *          The innermost operation (OR or AND).
    * @param   op2
    *          The outermost operation (OR or AND).
    * @param   debug
    *          Enables printing of tokens as they are provided by lexer and intermediate data.
    *
    * @return  CNF or DNF representation of the expression
    */
   public List<List<Term>> toNormalForm(Op op1, Op op2, boolean debug)
   {
      List<List<Term>> cnf = new ArrayList<>();
      List<EToken> rpn = toRPN(debug);
      Deque<Node> stack = new ArrayDeque<>();
      for (EToken token: rpn)
      {
         if (token instanceof Term)
         {
            stack.push(new Node((Term)token));
         }
         else if (token instanceof Op)
         {
            switch ((Op)token)
            {
            case NOT:
               stack.push(new Node(Op.NOT, stack.pop()));
               break;
            case AND:
            case OR:
               stack.push(new Node(stack.pop(), (Op)token, stack.pop()));
               break;
            }
         }
      }
      
      if (stack.isEmpty())
      {
         return cnf;
      }
      
      Node exprTree =  stack.pop();
      Node cnfTree = distribute(op1, op2, pushNots(exprTree));
      List<EToken> cnfExpr = toList(cnfTree, new ArrayList<>());
      if (debug)
      {
         System.out.println(cnfExpr);
      }
      List<Term> inner = new ArrayList<>();
      for (EToken t : cnfExpr)
      {
         if (t == Brackets.LPARENS || t == Brackets.RPARENS || t == op1)
         {
            continue;
         }
         if( t == op2)
         {
            cnf.add(inner);
            inner = new ArrayList<>();
         }
         else
         {
            inner.add((Term)t);
         }
      }
      cnf.add(inner);
      cnf.forEach(Collections::sort);
      cnf.sort(lexicographicalComparator());
      return cnf;
   }

   /**
    * Lexicographical comparator for lists of Comparable.
    *
    * @param   <T>
    *          Type parameter.
    *
    * @return  comparator
    */
   private static <T extends Comparable<T>> Comparator<List<T>> lexicographicalComparator(){
      return (l1, l2) -> {
         Iterator<T> i1 = l1.iterator();
         Iterator<T> i2 = l2.iterator();
         while(i1.hasNext() && i2.hasNext())
         {
            T v1 = i1.next();
            T v2 = i2.next();
            int rc = v1.compareTo(v2);
            if (rc != 0)
            {
               return rc;
            }
         }
         return i1.hasNext() ? 1 : i2.hasNext() ? -1 : 0;  
      };
   }

   /**
    * Recursively convert expression tree to linear representation.
    * 
    * @param   t
    *          Tree to be converted.
    * @param   list
    *          List to hold a linear representation.
    *
    * @return  linear representation of a tree
    */
   private List<EToken> toList(Node t, List<EToken> list)
   {
      if (t != null)
      {
         if (t.value instanceof Term)
         {
            list.add(t.value);
         } 
         else if (t.value == Op.AND || t.value == Op.OR)
         {
            list.add(Brackets.LPARENS);
            toList(t.leftNode, list);
            list.add(t.value);
            toList(t.rightNode, list);
            list.add(Brackets.RPARENS);
         } 
         else if (t.value == Op.NOT)
         {
            list.add(t.value);
            toList(t.negatedExpression, list);
         }
      }
      return list;
   }

   /**
    * Apply De Morgan's law and eliminate double negation.
    * 
    * @param   t
    *          Source tree.
    *
    * @return  converted tree
    * 
    */
   private Node pushNots(Node t) {
      if (t.value == Op.NOT) 
      {
          if (t.negatedExpression.value == Op.AND) {
              t = new Node(
                    pushNots(new Node(Op.NOT, t.negatedExpression.leftNode)),
                    Op.OR, 
                    pushNots(new Node(Op.NOT, t.negatedExpression.rightNode)));
          } 
          else if (t.negatedExpression.value == Op.OR) 
          {
              t = new Node(
                    pushNots(new Node(Op.NOT, t.negatedExpression.leftNode)),
                    Op.AND,
                    pushNots(new Node(Op.NOT, t.negatedExpression.rightNode)));
          } 
          else if (t.negatedExpression.value == Op.NOT) 
          {
              t = pushNots(t.negatedExpression.negatedExpression);
          }
      } 
      else if (t.value == Op.AND || t.value == Op.OR) 
      {
          t.leftNode  = pushNots(t.leftNode);
          t.rightNode = pushNots(t.rightNode);
      }
      return t;
  }
  
   /**
    * Apply distributive law.
    * 
    * @param   op1
    *          Innermost operation (OR or AND).
    * @param   op2
    *          Outermost operation (OR or AND).
    * @param   t
    *          Source tree.
    *
    * @return  converted tree
    * 
    */
   private Node distribute(Op op1, Op op2, Node t)
   {
      if (t.value == op2)
      {
         t.leftNode = distribute(op1, op2, t.leftNode);
         t.rightNode = distribute(op1, op2, t.rightNode);
      } 
      else if (t.value == op1)
      {
         Node p = distribute(op1, op2, t.leftNode);
         Node q = distribute(op1, op2, t.rightNode);
         if (p.value == op2)
         {
            t = new Node(distribute(op1, op2, new Node(p.leftNode, op1, q)), op2,
                  distribute(op1, op2, new Node(p.rightNode, op1, q)));
         }
         else if (q.value == op2)
         {
            t = new Node(distribute(op1, op2, new Node(p, op1, q.leftNode)), op2,
                  distribute(op1, op2, new Node(p, op1, q.rightNode)));
         } 
         else
         {
            t.leftNode = p;
            t.rightNode = q;
         }
      }
      return t;
   }

   /**
    * Expression tree node.
    */
   public static class Node
   {
      /** Node value (operation or term) */
      public final EToken value;

      /** Left child  (for binary operations) */
      public Node leftNode;

      /** Right child  (for binary operations) */
      public Node rightNode;

      /** Single child (for negation) */
      public final Node negatedExpression;

      /**
       * Constructor.
       *
       * @param   value
       *          Node value (operation or term).
       * @param   leftNode
       *          Left child (for binary operations).
       * @param   rightNode
       *          Right child  (for binary operations).
       * @param   negatedExpression
       *          Single child (for negation).
       */
      private Node(EToken value, Node leftNode, Node rightNode, Node negatedExpression)
      {
         super();
         this.value = value;
         this.leftNode = leftNode;
         this.rightNode = rightNode;
         this.negatedExpression = negatedExpression;
      }

      /**
       * Constructor for a binary operation node.
       * 
       * @param   leftNode
       *          Left operand.
       * @param   op
       *          Operation.
       * @param   rightNode
       *          Right operand.
       */
      public Node(Node leftNode, Op op, Node rightNode)
      {
         this(op, leftNode, rightNode, null);
      }

      /**
       * Constructor for a unary operation (negation) node.
       *
       * @param   op
       *          Operation.
       * @param   negatedExpression
       *          Operation argument.
       */
      public Node(Op op, Node negatedExpression)
      {
         this(op, null, null, negatedExpression);
      }

      /**
       * Constructor for a leaf node.
       * 
       * @param   term
       *          Node value.
       */
      public Node(Term term)
      {
         this(term, null, null, null);
      }
   }

   /**
    * Base interface for all tokens.
    */
   public interface EToken
   {

   }

   /**
    * Lexer which builds a token stream from the match expression.
    */
   private static class Lexer
   {
      /** A list o special characters that must be processed later on. */
      private static final String SPECIAL_CHARS = "*&|^!()";

      /** Cache of characters to be skipped during tokenization. */
      private static final BitSet SKIP_CHARS = new BitSet(128);

      /** Cache of characters to not be skipped during tokenization. */
      private static final BitSet NOT_SKIP_CHARS = new BitSet(128);

      /** Regular expression pattern defining characters to be ignored by {@link #readNextToken()}. */
      private final Pattern skipPattern;

      /** String reader stream. */
      private final Reader reader;

      /** Should matches be checked case-sensitively? */
      private final boolean caseSensitive;

      /** Token most recently read from the expression. */
      private EToken current = null;
      
      /** Distinct terms found in the expression. */
      private final Map<String, Term> terms = new LinkedHashMap<>();
      
      /** The ordinal for a next found term. */
      private int ord = 0;

      /** Used to signal the presence of an asterisk in the current token. */
      private boolean hasAsterisk = false;

      /**
       * Used to indicate if the token has normal characters between the asterisk and the
       * first {@link #SKIP_CHARS} encountered. True only if {@link #hasAsterisk} was true.
       */
      private boolean hasCharAfterAsterisk = false;
      
      /**
       * Constructor.
       * 
       * @param   reader
       *          Reader stream on the match expression text.
       * @param   delimiterPattern
       *          Regular expression pattern used to identify delimiters for splitting words.
       * @param   caseSensitive
       *          {@code true} to perform word match comparisons
       *          case-sensitively, {@code false} to ignore case.
       */
      private Lexer(Reader reader, Pattern delimiterPattern, boolean caseSensitive)
      {
         this.reader = reader;
         this.caseSensitive = caseSensitive;
         this.skipPattern = delimiterPattern;
      }

      /**
       * Return the most recently read token, or {@code null} if no more tokens are
       * available.
       * 
       * @return  Current token or {@code null}.
       * 
       * @throws  IOException
       *          If there is an I/O error reading from the stream.
       */
      EToken token() throws IOException
      {
         if (current == null)
         {
            readNextToken();
         }

         return current;
      }

      /**
       * Mark the current token as having been consumed.
       */
      void consume()
      {
         current = null;
      }

      /**
       * Check if a character is to be skipped when reading the next token. The method will be influenced
       * by the state of the current token (i.e. if the special asterisk mode is active or not).
       * <p>
       * Rules:
       * <ul>
       *    <li>Delimiters are skipped, except for {@link #SPECIAL_CHARS}</li>
       *    <li>Characters in [-,.] are part of a word only if followed by a digit and earlier in the token
       *        there wasn't any asterisk [*].</li>
       * </ul>
       *
       * @param   c
       *          The character to be checked.
       *
       * @return  {@code true} if the character should be skipped taking into account the current
       *          token state, or {@code false} otherwise.
       *
       * @throws  IOException
       *          If there is an I/O error reading from the stream.
       */
      private boolean isToBeSkipped(char c)
      throws IOException
      {
         if (SPECIAL_CHARS.indexOf(c) != -1)
         {
            if (c == '*')
            {
               hasAsterisk = true;
            }
            // special characters that must be processed later on
            return false;
         }
         // chars in "-,." are part of a word only if followed by a digit
         // if these chars are after an asterisk, then they are ignored and the above rule is canceled
         if (c == '-' || c == '.' || c == ',')
         {
            if (hasAsterisk)
            {
               hasCharAfterAsterisk = true;
               return true;
            }

            if (hasCharAfterAsterisk)
            {
               return true;
            }

            reader.mark(1);
            int lookAhead = reader.read();
            if (lookAhead == '*')
            {
               hasAsterisk = true;
            }

            // reset reader to the previous character before lookAhead was read
            reader.reset();
            return lookAhead < '0' || lookAhead > '9';
         }
         if (NOT_SKIP_CHARS.get(c))
         {
            return false;
         }
         if (SKIP_CHARS.get(c))
         {
            hasAsterisk = false;
            return true;
         }
         boolean shouldSkip = skipPattern.matcher(String.valueOf(c)).matches();
         if (shouldSkip)
         {
            hasAsterisk = false;
            SKIP_CHARS.set(c);
         }
         else
         {
            NOT_SKIP_CHARS.set(c);
         }
         return shouldSkip;
      }

      /**
       * Read the next token from the match expression. This will attempt to throw an early wildcard error
       * for the current token if normal chars (a, b, c, 1, etc...) are encountered after an asterisk [*].
       * The case of multiple asterisks is handled only after reading the full token.
       *
       * @throws  IOException
       *          If there is an I/O error reading from the stream.
       */
      private void readNextToken() throws IOException
      {
         StringBuilder buf = new StringBuilder();
         int next;
         boolean more = true;
         while (more)
         {
            reader.mark(1);
            if ((next = reader.read()) < 0)
            {
               break;
            }
            char c = (char) next;
            if (isToBeSkipped(c))
            {
               boolean shouldBreak = buf.length() > 0 &&
                                    (!hasAsterisk && !hasCharAfterAsterisk || SKIP_CHARS.get(c));
               if (shouldBreak)
               {
                  break;
               }

               continue;
            }

            switch (c)
            {
            case '&':
            case '|':
            case '^':
            case '!':
            case '(':
            case ')':
//            case '~': // This is *not* accepted by CONTAINS, added for
//                      // implementation testing
               more = false;
               hasAsterisk = false;
               hasCharAfterAsterisk = false;
               if (buf.length() > 0)
               {
                  // if a token is not finished being processed when we encounter this
                  // character, we reset the reader to just before this character and
                  // process that token first; the next time through, we'll process this
                  // character in the else block below
                  reader.reset();
               } 
               else
               {
                  switch (c)
                  {
                  case '&':
                     current = Op.AND;
                     break;
                  case '|':
                  case '!':
                  case '^':
                     current = Op.OR;
                     break;
//                  case '~': // This is *not* accepted by CONTAINS, added for
//                            // implementation testing
//                     current = Op.NOT;
//                     break;
                  case '(':
                     current = Brackets.LPARENS;
                     break;
                  case ')':
                     current = Brackets.RPARENS;
                     break;
                  }
               }
               break;
            default:
               if (c != '*' && (hasAsterisk || hasCharAfterAsterisk))
               {
                  // previously we had an asterisk in the word, and we are trying to append a char which
                  // is a normal one, like 'a', 'b', '1', etc... . The case of multiple asterisks
                  // is handled by the if statement below
                  wildcardError();
               }
               buf.append(c);
               break;
            }
         }
         if (buf.length() > 0)
         {
            String s = buf.toString();
            if (!caseSensitive)
            {
               s = s.toUpperCase();
            }
            if (s.endsWith("*"))
            {
               if (s.length() == 1)
               {
                  wildcardError();
                  return;
               }
               s = s.substring(0, s.length() - 1);
               if (s.indexOf('*') >= 0)
               {
                  wildcardError();
                  return;
               }
               String key = s + "%";
               current = terms.get(key); 
               if (current == null)
               {
                  ord++;
                  current = new StartsWith(s, ord);
                  terms.put(key, (Term)current);
               }
            } 
            else
            {
               current = terms.get(s); 
               if (current == null)
               {
                  ord++;
                  current = new Equals(s, ord);
                  terms.put(s, (Term)current);
               }
            }
         }
      }

      /**
       * Record or throw an error about incorrect wildcard syntax.
       * 
       * @throws ErrorConditionException always.
       */
      private void wildcardError()
      {
         String msg = "QBW syntax error - An asterisk (*) is allowed only at the end of a word";

         throw new ErrorConditionException(true, 4686, msg);
      }
   }

   /**
    * Logical operations
    */
   public enum Op implements EToken
   {
      NOT(1), AND(2), OR(3);

      /** Operation priority */
      public final int priority;

      /**
       * Constructor
       * 
       * @param   priority
       *          Operation priority
       */
      Op(int priority)
      {
         this.priority = priority;
      }
   }

   /**
    * Brackets
    */
   public enum Brackets implements EToken
   {
      LPARENS, RPARENS
   }

   /**
    * Base class for expression terms
    */
   public abstract static class Term implements EToken, Comparable<Term>
   {
      /** String to be compared with */
      private final String value;
      
      /** Order if the term */
      private final int order;

      /**
       * Evaluate predicate for a set of words
       * 
       * @param   words
       *          Given set of words.
       *
       * @return  <code>true</code> if any of the words satisfy the predicate
       */
      public abstract boolean evaluate(Set<String> words);

      /** 
       * Get SQL expression for the term.
       *
       * @return  SQL expression for the term.
       */
      public abstract String expr(); 
      
      /** 
       * Get CONTAINS expression for the term.
       *
       * @return  CONTAINS expression for the term.
       */
      public abstract String containsExpr(); 

      /**
       * Constructor.
       * 
       * @param   value
       *          String to be compared with.
       * @param   order
       *          Order if the term.
       */
      public Term(String value, int order)
      {
         super();
         this.value = value;
         this.order = order;
      }

      /**
       * Get the String to be compared with.
       * 
       * @return  String to be compared with
       */
      public String getValue()
      {
         return value;
      }

      /**
       * Get the term order in the original expression.
       * 
       * @return  The term order in the original expression
       */
      public int getOrder()
      {
         return order;
      }

      /**
       * @see java.lang.Comparable#compareTo(java.lang.Object)
       */
      @Override
      public int compareTo(Term o)
      {
         return Integer.compare(this.order, o.order);
      }
  }

   /**
    * "Equals" predicate
    */
   public static class Equals extends Term
   {
      /**
       * Constructor
       * 
       * @param   value
       *          String to be compared with.
       * @param   order
       *          Order if the term.
       */
      public Equals(String value, int order)
      {
         super(value, order);
      }

      /**
       * Convert to String.
       */
      @Override
      public String toString()
      {
         return "Equals(" + getValue() + ")";
      }

      /** 
       * Get SQL expression for the term.
       *
       * @return  SQL expression for the term.
       */
      public String expr()
      {
         return getValue();
      }

      /**
       * Evaluate predicate for a set of words
       * 
       * @param   words
       *          Given set of words.
       *
       * @return  <code>true</code> if any of the words satisfy the predicate
       */
      @Override
      public boolean evaluate(Set<String> words)
      {
         return words.contains(getValue());
      }

      /** 
       * Get CONTAINS expression for the term.
       *
       * @return  CONTAINS expression for the term.
       */
      @Override
      public String containsExpr()
      {
         return getValue();
      }
   }

   /**
    * "StartsWith" predicate
    */
   public static class StartsWith extends Term
   {
      /**
       * Constructor
       * 
       * @param   value
       *          String to be compared with.
       * @param   order
       *          Order if the term.
       */
      public StartsWith(String value, int order)
      {
         super(value, order);
      }

      /**
       * Convert to String.
       */
      @Override
      public String toString()
      {
         return "StartsWith(" + getValue() + ")";
      }

      /** 
       * Get SQL expression for the term.
       *
       * @return  SQL expression for the term.
       */
      public String expr()
      {
         return getValue() + "%";
      }

      /** 
       * Get CONTAINS expression for the term.
       *
       * @return  CONTAINS expression for the term.
       */
      @Override
      public String containsExpr()
      {
         return getValue() + "*";
      }

      /**
       * Evaluate predicate for a set of words.
       * 
       * @param   words
       *          Given set of words.
       *
       * @return  <code>true</code> if any of the words satisfy the predicate
       */
      @Override
      public boolean evaluate(Set<String> words)
      {
         return words.stream().anyMatch(w -> w.startsWith(getValue()));
      }
   }

   /**
    * Test program
    * 
    * @param   args command line arguments
    *
    * @throws  Exception if error
    */
   public static void main(String... args) throws Exception
   {
      System.out.println(args[0]);
      LogicalExpressionConverter lexp = new LogicalExpressionConverter(args[0], true);
      List<EToken> rpn = lexp.toRPN(true);
      System.out.printf("RPN: %s\n", rpn);
      List<List<Term>> cnf = lexp.toCNF(false);
      System.out.printf("CNF: %s\n", cnf);
      List<List<Term>> dnf = lexp.toDNF(false);
      System.out.printf("DNF: %s\n", dnf);
   }
}