QueryParser.java

package org.jsoup.select;

import org.jsoup.helper.Regex;
import org.jsoup.internal.StringUtil;
import org.jsoup.internal.QueryMode;
import org.jsoup.helper.Validate;
import org.jsoup.internal.CssMatching.AttributeOperator;
import org.jsoup.internal.CssMatching.CaseSensitivity;
import org.jsoup.nodes.CDataNode;
import org.jsoup.nodes.Comment;
import org.jsoup.nodes.DataNode;
import org.jsoup.nodes.LeafNode;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.ProcessingInstruction;
import org.jsoup.nodes.TextNode;
import org.jsoup.parser.TokenQueue;
import org.jsoup.select.HasEvaluator.Traversal;
import org.jspecify.annotations.Nullable;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.jsoup.internal.Normalizer.asciiLowerCase;
import static org.jsoup.internal.StringUtil.trimAsciiWhitespace;
import static org.jsoup.select.StructuralEvaluator.ImmediateParentRun;

/**
 * Parses a CSS selector into an Evaluator tree.
 */
public class QueryParser implements AutoCloseable {
    private final static char[] Combinators = {'>', '+', '~'}; // ' ' is also a combinator, but found implicitly
    private static final AttributeOperator[] AttributeOperators = AttributeOperator.values();
    private final static char[] SequenceEnders = {',', ')'};
    static final int MaxQueryComplexity = 128;

    private final TokenQueue tq;
    private final String query;
    private final boolean web;
    private boolean inNodeContext; // ::comment:contains should act on node value, vs element text
    private int complexity;

    /**
     * Create a new QueryParser.
     * @param query CSS query
     */
    private QueryParser(String query, boolean web) {
        this.web = web;
        Validate.notEmpty(query);
        query = trimAsciiWhitespace(query);
        this.query = query;
        this.tq = new TokenQueue(query);
    }

    /**
     Parse a CSS query into an Evaluator. If you are evaluating the same query repeatedly, it may be more efficient to
     parse it once and reuse the Evaluator.

     <p>A query may contain at most {@value #MaxQueryComplexity} combinators, node selectors, and {@code :has()},
     {@code :is()}, or {@code :not()} expressions in total, across all selector alternatives.</p>

     @param query CSS query
     @return Evaluator
     @see Selector selector query syntax
     @throws Selector.SelectorParseException if the CSS query is invalid or exceeds the complexity limit
     */
    public static Evaluator parse(String query) {
        return parse(query, QueryMode.current() == QueryMode.Web);
    }

    static Evaluator parse(String query, boolean web) {
        return Evaluator.withMode(web, () -> {
            try (QueryParser p = new QueryParser(query, web)) {
                return p.parse();
            } catch (IllegalArgumentException e) {
                throw new Selector.SelectorParseException(e.getMessage());
            }
        });
    }

    /**
     Parse the query. We use this simplified expression of the grammar:
     <pre>
     SelectorGroup   ::= Selector (',' Selector)*
     Selector        ::= [ Combinator ] SimpleSequence ( Combinator SimpleSequence )*
     SimpleSequence  ::= [ TypeSelector ] ( ID | Class | Attribute | Pseudo )*
     Pseudo           ::= ':' Name [ '(' SelectorGroup ')' ]
     Combinator      ::= S+         // descendant (whitespace)
     | '>'       // child
     | '+'       // adjacent sibling
     | '~'       // general sibling
     </pre>

     See <a href="https://www.w3.org/TR/selectors-4/#grammar">selectors-4</a> for the real thing
     */
    Evaluator parse() {
        Evaluator eval = parseSelectorGroup().evaluator();
        tq.consumeWhitespace();
        if (!tq.isEmpty())
            throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query, tq.remainder());
        return eval;
    }

    /** Parses an ordered group of comma-separated selectors. */
    private SelectorGroup parseSelectorGroup() {
        List<ComplexSelector> selectors = new ArrayList<>();
        do {
            selectors.add(parseSelector());
        } while (tq.matchChomp(','));
        return new SelectorGroup(selectors);
    }

    private ComplexSelector parseSelector() {
        tq.consumeWhitespace();
        char leading = tq.matchesAny(Combinators) ? tq.consume() : 0;
        if (leading != 0) consumeStructuralStep();
        List<Evaluator> compounds = Collections.singletonList(parseSimpleSequence());
        List<Character> combinators = Collections.emptyList();
        while (true) {
            char combinator = tq.consumeWhitespace() ? ' ' : 0;
            if (tq.matchesAny(Combinators)) combinator = tq.consume();
            else if (tq.matchesAny(SequenceEnders) || tq.isEmpty()) break;
            if (combinator == 0) break;
            consumeStructuralStep();
            if (combinators.isEmpty()) {
                compounds = new ArrayList<>(compounds);
                combinators = new ArrayList<>();
            }
            combinators.add(combinator);
            compounds.add(parseSimpleSequence());
        }
        return new ComplexSelector(compounds, combinators, leading);
    }

    /** Stores selector parts and relationships, such as {@code div.product > a}, for ordinary queries and {@code :has()}. */
    private final class ComplexSelector {
        final List<Evaluator> compounds;
        final List<Character> combinators;
        final char leadingCombinator;

        ComplexSelector(List<Evaluator> compounds, List<Character> combinators, char leading) {
            this.compounds = compounds;
            this.combinators = combinators;
            leadingCombinator = leading;
        }

        Evaluator evaluator() {
            return evaluator(leadingCombinator != 0 ? new StructuralEvaluator.QueryRoot() : null);
        }

        Evaluator relativeEvaluator() {
            if (compounds.size() == 1 && (leadingCombinator != '+' || !wantsNodes()))
                return compounds.get(0); // traversal already guarantees the leading relationship
            return evaluator(leadingCombinator != 0 || web ? new StructuralEvaluator.RelativeAnchor() : null);
        }

        private Evaluator evaluator(@Nullable Evaluator anchor) {
            Evaluator left = compounds.get(0);
            if (anchor != null) left = combinator(anchor, leadingCombinator == 0 ? ' ' : leadingCombinator, left);
            for (int i = 0; i < combinators.size(); i++)
                left = combinator(left, combinators.get(i), compounds.get(i + 1));
            return left;
        }

        boolean wantsNodes() {
            for (Evaluator compound : compounds) if (compound.wantsNodes()) return true;
            return false;
        }

        @Override public String toString() {
            StringBuilder out = new StringBuilder();
            if (leadingCombinator != 0) out.append(leadingCombinator).append(' ');
            out.append(compounds.get(0));
            for (int i = 0; i < combinators.size(); i++) {
                char relation = combinators.get(i);
                out.append(' ');
                if (relation != ' ') out.append(relation).append(' ');
                out.append(compounds.get(i + 1));
            }
            return out.toString();
        }
    }

    /** A parsed selector group that can be compiled or rendered in source order. */
    private static final class SelectorGroup {
        final List<ComplexSelector> selectors;

        /** Creates a group from its selectors in source order. */
        SelectorGroup(List<ComplexSelector> selectors) {
            this.selectors = selectors;
        }

        /** Build the selectors into a single evaluator. */
        Evaluator evaluator() {
            Evaluator first = selectors.get(0).evaluator();
            if (selectors.size() == 1)
                return first;

            ArrayList<Evaluator> evaluators = new ArrayList<>(selectors.size());
            // preserve a leading Or's flat evaluation order, e.g. the two alternatives compiled for *|tag
            if (first instanceof CombiningEvaluator.Or)
                evaluators.addAll(((CombiningEvaluator.Or) first).evaluators);
            else
                evaluators.add(first);
            for (int i = 1; i < selectors.size(); i++)
                evaluators.add(selectors.get(i).evaluator());
            return new CombiningEvaluator.Or(evaluators);
        }

        @Override public String toString() {
            return StringUtil.join(selectors, ", ");
        }
    }

    Evaluator parseSimpleSequence() {
        // SimpleSequence ::= TypeSelector? ( Hash | Class | Pseudo )*
        Evaluator first = null;
        tq.consumeWhitespace();

        // one optional type selector
        if (tq.matchesWord() || tq.matches("*|"))
            first = byTag();
        else if (tq.matchChomp('*'))
            first = new Evaluator.AllElements();

        // use the first subclass when there is no type selector
        if (first == null)
            first = parseSubclass();

        if (first == null)
            throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query, tq.remainder());

        return parseRemainingSubclasses(first);
    }

    /** Parses and combines any subclasses that follow the first evaluator. */
    private Evaluator parseRemainingSubclasses(Evaluator first) {
        Evaluator next = parseSubclass();
        if (next == null)
            return first;

        ArrayList<Evaluator> evaluators = new ArrayList<>(4);
        evaluators.add(first);
        do {
            evaluators.add(next);
        } while ((next = parseSubclass()) != null);
        return new CombiningEvaluator.And(evaluators);
    }

    static Evaluator combinator(Evaluator left, char combinator, Evaluator right) {
        switch (combinator) {
            case '>':
                ImmediateParentRun run = left instanceof ImmediateParentRun ?
                    (ImmediateParentRun) left : new ImmediateParentRun(left);
                run.add(right);
                return run;
            case ' ':
                return new CombiningEvaluator.And(new StructuralEvaluator.Ancestor(left), right);
            case '+':
                return new CombiningEvaluator.And(new StructuralEvaluator.ImmediatePreviousSibling(left), right);
            case '~':
                return new CombiningEvaluator.And(new StructuralEvaluator.PreviousSibling(left), right);
            default:
                throw new Selector.SelectorParseException("Unknown combinator '%s'", combinator);
        }
    }

    @Nullable Evaluator parseSubclass() {
        //  Subclass: ID | Class | Attribute | Pseudo
        if      (tq.matchChomp('#'))    return byId();
        else if (tq.matchChomp('.'))    return byClass();
        else if (tq.matches('['))       return byAttribute();
        else if (tq.matchChomp("::"))   return parseNodeSelector(); // ::comment etc
        else if (tq.matchChomp(':'))    return parsePseudoSelector();
        else                            return null;
    }

    private Evaluator parsePseudoSelector() {
        final String pseudo = tq.consumeCssIdentifier();
        switch (pseudo) {
            case "lt":
                return new Evaluator.IndexLessThan(consumeIndex());
            case "gt":
                return new Evaluator.IndexGreaterThan(consumeIndex());
            case "eq":
                return new Evaluator.IndexEquals(consumeIndex());
            case "has":
                return has();
            case "is":
                return is();
            case "contains":
                return contains(false);
            case "containsOwn":
                return contains(true);
            case "containsWholeText":
                return containsWholeText(false);
            case "containsWholeOwnText":
                return containsWholeText(true);
            case "containsData":
                return containsData();
            case "matches":
                return matches(false);
            case "matchesAttr":
                return matchesAttr();
            case "matchesOwn":
                return matches(true);
            case "matchesWholeText":
                return matchesWholeText(false);
            case "matchesWholeOwnText":
                return matchesWholeText(true);
            case "not":
                return not();
            case "nth-child":
                return cssNthChild(false, false);
            case "nth-last-child":
                return cssNthChild(true, false);
            case "nth-of-type":
                return cssNthChild(false, true);
            case "nth-last-of-type":
                return cssNthChild(true, true);
            case "first-child":
                return new Evaluator.IsFirstChild();
            case "last-child":
                return new Evaluator.IsLastChild();
            case "first-of-type":
                return new Evaluator.IsFirstOfType();
            case "last-of-type":
                return new Evaluator.IsLastOfType();
            case "only-child":
                return new Evaluator.IsOnlyChild();
            case "only-of-type":
                return new Evaluator.IsOnlyOfType();
            case "empty":
                return new Evaluator.IsEmpty();
            case "blank":
                return new NodeEvaluator.BlankValue();
            case "scope":
                return new Evaluator.IsScope();
            case "root":
                return new Evaluator.IsRoot();
            case "matchText": {
                throw new Selector.SelectorParseException(":matchText is no longer supported. Use Element#selectNodes(String, Class) with selector ::text and class TextNode instead."); // todo remove this in 1.25.1
            }
            default:
                throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query, tq.remainder());
        }
    }

    /** Parses a node selector and its subclasses in node-value context. */
    private Evaluator parseNodeSelector() {
        consumeStructuralStep();
        final String pseudo = tq.consumeCssIdentifier();
        Evaluator nodeType;
        switch (pseudo) {
            case "node":
                nodeType = new NodeEvaluator.InstanceType(Node.class, pseudo);
                break;
            case "leafnode":
                nodeType = new NodeEvaluator.InstanceType(LeafNode.class, pseudo);
                break;
            case "text":
                nodeType = new NodeEvaluator.InstanceType(TextNode.class, pseudo);
                break;
            case "comment":
                nodeType = new NodeEvaluator.InstanceType(Comment.class, pseudo);
                break;
            case "data":
                nodeType = new NodeEvaluator.InstanceType(DataNode.class, pseudo);
                break;
            case "cdata":
                nodeType = new NodeEvaluator.InstanceType(CDataNode.class, pseudo);
                break;
            case "pi":
                nodeType = piSelector();
                break;
            default:
                throw new Selector.SelectorParseException(
                    "Could not parse query '%s': unknown node type '::%s'", query, pseudo);
        }

        // nested selectors can enter node context again, e.g. ::comment:not(::text):contains(foo)
        // restore the caller's context even if parsing a subclass throws
        boolean previousNodeContext = inNodeContext;
        inNodeContext = true;
        try {
            return parseRemainingSubclasses(nodeType);
        } finally {
            inNodeContext = previousNodeContext;
        }
    }

    /** Parses an optional processing-instruction target. */
    private Evaluator piSelector() {
        if (!tq.matches('('))
            return new NodeEvaluator.InstanceType(ProcessingInstruction.class, "pi");

        String argument = trimAsciiWhitespace(consumeParens());
        Validate.notEmpty(argument, "::pi target must not be empty");
        try (TokenQueue targetQueue = new TokenQueue(argument)) {
            String target = targetQueue.consumeCssIdentifier();
            targetQueue.consumeWhitespace();
            Validate.isTrue(targetQueue.isEmpty(), "::pi target must be a CSS identifier");
            return new NodeEvaluator.ProcessingInstructionTarget(target);
        }
    }

    private Evaluator byId() {
        String id = tq.consumeCssIdentifier();
        Validate.notEmpty(id);
        return new Evaluator.Id(id);
    }

    private Evaluator byClass() {
        String className = tq.consumeCssIdentifier();
        Validate.notEmpty(className);
        return new Evaluator.Class(className.trim());
    }

    private Evaluator byTag() {
        String tagName = tq.consumeElementSelector();
        Validate.notEmpty(tagName);

        // namespaces:
        if (tagName.startsWith("*|")) { // namespaces: wildcard match equals(tagName) or ending in ":"+tagName
            String plainTag = tagName.substring(2); // strip *|
            return new CombiningEvaluator.Or(
                new Evaluator.Tag(plainTag),
                new Evaluator.TagEndsWith(":" + plainTag)
            );
        } else if (tagName.endsWith("|*")) { // ns|*
            String ns = tagName.substring(0, tagName.length() - 2) + ":"; // strip |*, to ns:
            return new Evaluator.TagStartsWith(ns);
        } else if (tagName.contains("|")) { // flip "abc|def" to "abc:def"
            tagName = tagName.replace("|", ":");
        }

        return new Evaluator.Tag(tagName);
    }

    private Evaluator byAttribute() {
        if (!web) return LegacyQueryParser.attribute(tq, query);
        String attribute = peekCssAttribute(query, tq.pos(), true);
        try (TokenQueue css = new TokenQueue(attribute)) {
            Evaluator eval = cssAttribute(css, true);
            consumeAttribute(tq, attribute);
            return eval;
        }
    }

    /** Reads the contents of {@code [...]} without advancing the query, allowing legacy parsing to retry. */
    static String peekCssAttribute(String query, int offset, boolean extendedValues) {
        int start = offset + 1;
        char quote = 0;
        int depth = 1;
        for (int pos = start; pos < query.length(); pos++) {
            char c = query.charAt(pos);
            if (c == '\\') {
                pos++;
                continue;
            }
            if (quote != 0) {
                if (c == quote) quote = 0;
            } else if (c == '\'' || c == '"') {
                quote = c;
            } else if (extendedValues && c == '[') {
                depth++; // preserve unquoted values such as http://[::1]/
            } else if (c == ']' && --depth == 0) {
                return query.substring(start, pos);
            }
        }
        throw new Selector.SelectorParseException("Did not find balanced marker at '%s'", query.substring(start));
    }

    static void consumeAttribute(TokenQueue tq, String attribute) {
        for (int i = 0; i < attribute.length() + 2; i++) tq.advance();
    }

    /**
     Parses an attribute selector. With {@code extendedValues} false, unquoted values must be CSS identifiers
     so legacy values ending in {@code i} or {@code s} are not mistaken for case flags.
     */
    static Evaluator cssAttribute(TokenQueue cq, boolean extendedValues) {
        cq.consumeWhitespace();
        boolean prefix = cq.matchChomp('^');
        if (prefix && cq.isEmpty()) return new Evaluator.AttributeStarting("");
        String key = consumeAttributeName(cq, cq.matchChomp('*') ? "*" : cq.consumeCssIdentifier());
        cq.consumeWhitespace();
        if (cq.isEmpty()) return prefix ? new Evaluator.AttributeStarting(key) : key.equals("*") ?
            new Evaluator.AttributeStarting("") : new Evaluator.Attribute(key);
        Validate.isFalse(prefix || key.equals("*"), "Attribute value comparison requires an attribute name");
        AttributeOperator operator = null;
        for (AttributeOperator candidate : AttributeOperators) {
            if (cq.matchChomp(candidate.symbol)) { operator = candidate; break; }
        }
        Validate.notNull(operator, "Attribute operator expected");
        AttributeValue operand = consumeAttributeValue(cq, extendedValues);
        String value = operand.value;
        CaseSensitivity flag = operand.flag;
        switch (operator) {
            case Equals: return new Evaluator.AttributeWithValue(key, value, flag);
            case NotEquals: return new Evaluator.AttributeWithValueNot(key, value, flag);
            case StartsWith: return new Evaluator.AttributeWithValueStarting(key, value, flag);
            case EndsWith: return new Evaluator.AttributeWithValueEnding(key, value, flag);
            case Contains: return new Evaluator.AttributeWithValueContaining(key, value, flag);
            case ContainsToken: return new Evaluator.AttributeWithValueContainingToken(key, value, flag);
            case DashMatch: return new Evaluator.AttributeWithValueDashPrefix(key, value, flag);
            default: throw new IllegalArgumentException("Unknown attribute operator: " + operator);
        }
    }

    private static AttributeValue consumeAttributeValue(TokenQueue cq, boolean extendedValues) {
        cq.consumeWhitespace();
        boolean quoted = cq.matches('\'') || cq.matches('"');
        String value;
        CaseSensitivity flag = CaseSensitivity.Default;
        if (extendedValues && !quoted) {
            StringBuilder out = new StringBuilder();
            String whitespace = "";
            while (!cq.isEmpty()) {
                String part = cq.consumeCssValue(true);
                StringBuilder nextWhitespace = new StringBuilder();
                while (cq.matchesWhitespace()) nextWhitespace.append(cq.consume());
                CaseSensitivity nextFlag = attributeCase(part);
                if (cq.isEmpty() && out.length() != 0 && nextFlag != CaseSensitivity.Default)
                    flag = nextFlag;
                else
                    out.append(whitespace).append(part);
                whitespace = nextWhitespace.toString();
            }
            value = out.toString();
        } else {
            Validate.isFalse(cq.isEmpty(), "Attribute value expected");
            value = cq.consumeCssValue();
            boolean separated = cq.consumeWhitespace();
            if (!cq.isEmpty()) {
                Validate.isTrue(quoted || separated, "Attribute flag must follow a quoted value or whitespace");
                String name = cq.consumeCssIdentifier();
                flag = attributeCase(name);
                Validate.isTrue(flag != CaseSensitivity.Default, "Unknown attribute case flag: " + asciiLowerCase(name));
                cq.consumeWhitespace();
            }
        }
        Validate.isTrue(cq.isEmpty(), "Unexpected token after attribute value");
        return new AttributeValue(value, flag);
    }

    private static final class AttributeValue {
        final String value;
        final CaseSensitivity flag;

        AttributeValue(String value, CaseSensitivity flag) {
            this.value = value;
            this.flag = flag;
        }
    }

    private static CaseSensitivity attributeCase(String value) {
        String name = asciiLowerCase(value);
        return name.equals("i") ? CaseSensitivity.Insensitive :
            name.equals("s") ? CaseSensitivity.Sensitive : CaseSensitivity.Default;
    }

    /**
     Allows attribute names containing a colon, including {@code abs:href}.
     */
    private static String consumeAttributeName(TokenQueue cq, String key) {
        Validate.notEmpty(key, "Attribute name expected");
        if (cq.matchChomp(':')) {
            String local = cq.consumeCssIdentifier();
            Validate.isFalse(key.equals("abs") && local.isEmpty(), "Absolute attribute key must have a name");
            Validate.notEmpty(local, "Attribute name expected");
            key += ":" + local;
        }
        return key;
    }

    /**
     Parses {@code :matchesAttr()}, leaving regex escapes intact.
     */
    private Evaluator matchesAttr() {
        Validate.isTrue(tq.matchChomp('('), ":matchesAttr requires an attribute name and regex");
        tq.consumeWhitespace();
        String key = consumeAttributeName(tq, tq.consumeCssIdentifier());
        tq.consumeWhitespace();
        Validate.isTrue(tq.matchChomp(','), ":matchesAttr requires an attribute name and regex");
        tq.consumeWhitespace();
        String pattern = tq.chompBalancedRemainder('(', ')');
        Validate.notEmpty(pattern, ":matchesAttr requires a regex");
        return new Evaluator.AttributeWithValueMatching(key, Regex.compile(pattern));
    }

    //pseudo selectors :first-child, :last-child, :nth-child, ...
    private static final Pattern NthStepOffset = Pattern.compile("(([+-])?(\\d+)?)n(\\s*([+-])?\\s*\\d+)?", Pattern.CASE_INSENSITIVE);
    private static final Pattern NthOffset = Pattern.compile("([+-])?(\\d+)");

    private Evaluator cssNthChild(boolean last, boolean ofType) {
        String arg = asciiLowerCase(trimAsciiWhitespace(consumeParens())); // arg is like "odd", or "-n+2", within nth-child(odd)
        final int step, offset;
        if ("odd".equals(arg)) {
            step = 2;
            offset = 1;
        } else if ("even".equals(arg)) {
            step = 2;
            offset = 0;
        } else {
            Matcher stepOffsetM, stepM;
            if ((stepOffsetM = NthStepOffset.matcher(arg)).matches()) {
                if (stepOffsetM.group(3) != null) // has digits, like 3n+2 or -3n+2
                    step = Integer.parseInt(stepOffsetM.group(1).replaceFirst("^\\+", ""));
                else // no digits, might be like n+2, or -n+2. if group(2) == "-", it���s -1;
                    step = "-".equals(stepOffsetM.group(2)) ? -1 : 1;
                offset =
                    stepOffsetM.group(4) != null ? Integer.parseInt(stepOffsetM.group(4).replaceFirst("^\\+", "")) : 0;
            } else if ((stepM = NthOffset.matcher(arg)).matches()) {
                step = 0;
                offset = Integer.parseInt(stepM.group().replaceFirst("^\\+", ""));
            } else {
                throw new Selector.SelectorParseException("Could not parse nth-index '%s': unexpected format", arg);
            }
        }

        return ofType
            ? (last ? new Evaluator.IsNthLastOfType(step, offset) : new Evaluator.IsNthOfType(step, offset))
            : (last ? new Evaluator.IsNthLastChild(step, offset) : new Evaluator.IsNthChild(step, offset));
    }

    /** Consumes a parenthesized argument, requiring its opening delimiter. */
    private String consumeParens() {
        Validate.isTrue(tq.matches('('), "Expected '(' in query '" + query + "'");
        return tq.chompBalanced('(', ')');
    }

    private int consumeIndex() {
        String index = consumeParens().trim();
        Validate.isTrue(StringUtil.isNumeric(index), "Index must be numeric");
        return Integer.parseInt(index);
    }

    /** Parses {@code :has()}, grouping consecutive alternatives that can share a traversal. */
    private Evaluator has() {
        String err = ":has() must have a selector";
        SelectorGroup group = parseNestedSelectorGroup(err);
        return new HasEvaluator(group.toString(), planTraversals(group));
    }

    /**
     Groups alternatives that check the same elements.
     For example, {@code :has(> a, > span)} checks the children once.
     */
    private List<Traversal> planTraversals(SelectorGroup group) {
        List<Traversal> traversals = new ArrayList<>();
        ArrayList<Evaluator> alternatives = new ArrayList<>();
        boolean startsAtSibling = false, descend = false, nextSiblingOnly = false;
        for (ComplexSelector selector : group.selectors) {
            char leading = selector.leadingCombinator;
            boolean siblings = leading == '+' || leading == '~';
            boolean trees = selector.compounds.size() > 1 || !siblings && leading != '>';
            boolean nextOnly = leading == '+' && !selector.wantsNodes() &&
                !selector.combinators.contains('+') && !selector.combinators.contains('~');
            if (!alternatives.isEmpty() &&
                (siblings != startsAtSibling || trees != descend || nextOnly != nextSiblingOnly)) {
                traversals.add(new Traversal(alternatives, startsAtSibling, descend, nextSiblingOnly));
                alternatives = new ArrayList<>();
            }
            alternatives.add(selector.relativeEvaluator());
            startsAtSibling = siblings;
            descend = trees;
            nextSiblingOnly = nextOnly;
        }
        traversals.add(new Traversal(alternatives, startsAtSibling, descend, nextSiblingOnly));
        return traversals;
    }

    // pseudo selector :is()
    private Evaluator is() {
        return parseNested(StructuralEvaluator.Is::new, ":is() must have a selector");
    }

    /** Parses a nested selector group and applies its compiled evaluator. */
    private Evaluator parseNested(Function<Evaluator, Evaluator> func, String err) {
        return func.apply(parseNestedSelectorGroup(err).evaluator());
    }

    /** Parses a non-empty selector group enclosed in parentheses. */
    private SelectorGroup parseNestedSelectorGroup(String err) {
        Validate.isTrue(tq.matchChomp('('), err);
        tq.consumeWhitespace();
        Validate.isTrue(!tq.isEmpty() && !tq.matches(')'), err);
        consumeStructuralStep();
        SelectorGroup group = parseSelectorGroup();
        Validate.isTrue(tq.matchChomp(')'), err);
        return group;
    }

    /** Bounds parser and evaluator recursion by counting structural steps across the whole query. */
    private void consumeStructuralStep() {
        Validate.isTrue(++complexity <= MaxQueryComplexity, "Query complexity exceeds maximum of " + MaxQueryComplexity);
    }

    // pseudo selector :contains(text), containsOwn(text)
    private Evaluator contains(boolean own) {
        String query = own ? ":containsOwn" : ":contains";
        String searchText = TokenQueue.unescape(consumeParens());
        Validate.notEmpty(searchText, query + "(text) query must not be empty");

        if (inNodeContext)
            return new NodeEvaluator.ContainsValue(searchText);

        return own
            ? new Evaluator.ContainsOwnText(searchText)
            : new Evaluator.ContainsText(searchText);
    }

    private Evaluator containsWholeText(boolean own) {
        String query = own ? ":containsWholeOwnText" : ":containsWholeText";
        String searchText = TokenQueue.unescape(consumeParens());
        Validate.notEmpty(searchText, query + "(text) query must not be empty");
        return own
            ? new Evaluator.ContainsWholeOwnText(searchText)
            : new Evaluator.ContainsWholeText(searchText);
    }

    // pseudo selector :containsData(data)
    private Evaluator containsData() {
        String searchText = TokenQueue.unescape(consumeParens());
        Validate.notEmpty(searchText, ":containsData(text) query must not be empty");
        return new Evaluator.ContainsData(searchText);
    }

    // :matches(regex), matchesOwn(regex)
    private Evaluator matches(boolean own) {
        String query = own ? ":matchesOwn" : ":matches";
        Regex pattern = consumeRegex(query);

        if (inNodeContext)
            return new NodeEvaluator.MatchesValue(pattern);

        return own
            ? new Evaluator.MatchesOwn(pattern)
            : new Evaluator.Matches(pattern);
    }

    // :matchesWholeText(regex), matchesWholeOwnText(regex)
    private Evaluator matchesWholeText(boolean own) {
        String query = own ? ":matchesWholeOwnText" : ":matchesWholeText";
        Regex pattern = consumeRegex(query);
        return own
            ? new Evaluator.MatchesWholeOwnText(pattern)
            : new Evaluator.MatchesWholeText(pattern);
    }

    /** Consumes and compiles a non-empty regex argument, preserving its escapes. */
    private Regex consumeRegex(String pseudo) {
        String regex = consumeParens();
        Validate.notEmpty(regex, pseudo + "(regex) query must not be empty");
        return Regex.compile(regex);
    }

    /** Parses the selector group negated by {@code :not()}. */
    private Evaluator not() {
        return parseNested(StructuralEvaluator.Not::new, ":not(selector) subselect must not be empty");
    }

    @Override
    public String toString() {
        return query;
    }

    @Override
    public void close() {
        tq.close();
    }
}