package org.das2.util;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
/**
* Small Boolean full-text search parser. Requires Java 8 or later.
*
*
Operators (case-insensitive): NOT, AND, OR, in descending precedence.
* Adjacent terms imply AND. Parentheses group expressions; double quotes
* match literal phrases. A minus sign at the start of a term means NOT.
* Searches are case-insensitive substring matches, not whole-word matches.
*
*
Example: {@code BooleanSearch.compile("(rbsp OR themis) AND density")}
*/
public final class BooleanSearch {
private BooleanSearch() { }
/** Compiles a query once for use against many strings. */
public static Predicate compile(String query) {
if (query == null) {
throw new IllegalArgumentException("Query must not be null");
}
return compileTokens(tokenize(query));
}
/**
* Compiles a partially typed query for interactive search fields.
* Incomplete trailing operators and empty opening groups are discarded;
* unmatched opening parentheses are closed automatically. An unfinished
* quoted phrase is treated as a phrase extending to the end of the query.
* Other syntax errors are still reported.
*/
public static Predicate compileLenient(String query) {
if (query == null) {
throw new IllegalArgumentException("Query must not be null");
}
List tokens = tokenize(query, true);
// Strip incomplete suffixes, including "OR (" and "AND NOT".
boolean changed;
do {
changed = false;
while (!tokens.isEmpty()) {
Type type = tokens.get(tokens.size() - 1).type;
if (type == Type.AND || type == Type.OR || type == Type.NOT) {
tokens.remove(tokens.size() - 1);
changed = true;
} else {
break;
}
}
if (!tokens.isEmpty()
&& tokens.get(tokens.size() - 1).type == Type.LPAREN) {
tokens.remove(tokens.size() - 1);
changed = true;
}
} while (changed);
int depth = 0;
for (Token token : tokens) {
if (token.type == Type.LPAREN) {
depth++;
} else if (token.type == Type.RPAREN) {
depth--;
if (depth < 0) {
throw new IllegalArgumentException("Unexpected closing ')'");
}
}
}
while (depth-- > 0) {
tokens.add(new Token(Type.RPAREN, ")"));
}
return compileTokens(tokens);
}
private static Predicate compileTokens(List tokens) {
Parser parser = new Parser(tokens);
if (tokens.isEmpty()) {
return text -> true;
}
Predicate result = parser.parseOr();
if (parser.peek() != null) {
throw new IllegalArgumentException("Unexpected token: " + parser.peek().value);
}
return text -> result.test(text == null ? "" : text.toLowerCase(Locale.ROOT));
}
/** Convenience method for one-off matches. */
public static boolean matches(String query, String text) {
return compile(query).test(text);
}
private enum Type { WORD, AND, OR, NOT, LPAREN, RPAREN }
private static final class Token {
final Type type;
final String value;
Token(Type type, String value) {
this.type = type;
this.value = value;
}
}
private static List tokenize(String query) {
return tokenize(query, false);
}
private static List tokenize(String query, boolean lenient) {
List tokens = new ArrayList<>();
int i = 0;
while (i < query.length()) {
char ch = query.charAt(i);
if (Character.isWhitespace(ch)) {
i++;
continue;
}
if (ch == '(' || ch == ')') {
tokens.add(new Token(ch == '(' ? Type.LPAREN : Type.RPAREN,
Character.toString(ch)));
i++;
continue;
}
if (ch == '"') {
int start = ++i;
while (i < query.length() && query.charAt(i) != '"') {
i++;
}
if (i == query.length() && !lenient) {
throw new IllegalArgumentException("Unterminated quoted phrase");
}
tokens.add(new Token(Type.WORD, query.substring(start, i)));
if (i < query.length()) {
i++;
}
continue;
}
if (ch == '-') {
tokens.add(new Token(Type.NOT, "-"));
i++;
continue;
}
int start = i;
while (i < query.length() && !Character.isWhitespace(query.charAt(i))
&& query.charAt(i) != '(' && query.charAt(i) != ')') {
i++;
}
String word = query.substring(start, i);
Type type;
switch (word.toUpperCase(Locale.ROOT)) {
case "AND": type = Type.AND; break;
case "OR": type = Type.OR; break;
case "NOT": type = Type.NOT; break;
default: type = Type.WORD; break;
}
tokens.add(new Token(type, word));
}
return tokens;
}
private static final class Parser {
final List tokens;
int pos;
Parser(List tokens) { this.tokens = tokens; }
Token peek() {
return pos < tokens.size() ? tokens.get(pos) : null;
}
boolean accept(Type type) {
if (peek() != null && peek().type == type) {
pos++;
return true;
}
return false;
}
Predicate primary() {
if (accept(Type.LPAREN)) {
Predicate inner = parseOr();
if (!accept(Type.RPAREN)) {
throw new IllegalArgumentException("Missing closing ')'");
}
return inner;
}
Token token = peek();
if (token != null && token.type == Type.WORD) {
pos++;
String term = token.value.toLowerCase(Locale.ROOT);
return text -> text.contains(term);
}
throw new IllegalArgumentException("Expected a search term or '('");
}
Predicate parseNot() {
if (accept(Type.NOT)) {
return primaryOrNot().negate();
}
return primary();
}
private Predicate primaryOrNot() {
return parseNot();
}
Predicate parseAnd() {
Predicate left = parseNot();
while (true) {
if (accept(Type.AND)) {
left = left.and(parseNot());
} else if (peek() != null &&
(peek().type == Type.WORD || peek().type == Type.LPAREN
|| peek().type == Type.NOT)) {
left = left.and(parseNot());
} else {
return left;
}
}
}
Predicate parseOr() {
Predicate left = parseAnd();
while (accept(Type.OR)) {
left = left.or(parseAnd());
}
return left;
}
}
}