001package org.jsoup.select; 002 003import org.jsoup.helper.Regex; 004import org.jsoup.internal.StringUtil; 005import org.jsoup.internal.QueryMode; 006import org.jsoup.helper.Validate; 007import org.jsoup.internal.CssMatching.AttributeOperator; 008import org.jsoup.internal.CssMatching.CaseSensitivity; 009import org.jsoup.nodes.CDataNode; 010import org.jsoup.nodes.Comment; 011import org.jsoup.nodes.DataNode; 012import org.jsoup.nodes.LeafNode; 013import org.jsoup.nodes.Node; 014import org.jsoup.nodes.ProcessingInstruction; 015import org.jsoup.nodes.TextNode; 016import org.jsoup.parser.TokenQueue; 017import org.jsoup.select.HasEvaluator.Traversal; 018import org.jspecify.annotations.Nullable; 019 020import java.util.ArrayList; 021import java.util.Collections; 022import java.util.List; 023import java.util.function.Function; 024import java.util.regex.Matcher; 025import java.util.regex.Pattern; 026 027import static org.jsoup.internal.Normalizer.asciiLowerCase; 028import static org.jsoup.internal.StringUtil.trimAsciiWhitespace; 029import static org.jsoup.select.StructuralEvaluator.ImmediateParentRun; 030 031/** 032 * Parses a CSS selector into an Evaluator tree. 033 */ 034public class QueryParser implements AutoCloseable { 035 private final static char[] Combinators = {'>', '+', '~'}; // ' ' is also a combinator, but found implicitly 036 private static final AttributeOperator[] AttributeOperators = AttributeOperator.values(); 037 private final static char[] SequenceEnders = {',', ')'}; 038 static final int MaxQueryComplexity = 128; 039 040 private final TokenQueue tq; 041 private final String query; 042 private final boolean web; 043 private boolean inNodeContext; // ::comment:contains should act on node value, vs element text 044 private int complexity; 045 046 /** 047 * Create a new QueryParser. 048 * @param query CSS query 049 */ 050 private QueryParser(String query, boolean web) { 051 this.web = web; 052 Validate.notEmpty(query); 053 query = trimAsciiWhitespace(query); 054 this.query = query; 055 this.tq = new TokenQueue(query); 056 } 057 058 /** 059 Parse a CSS query into an Evaluator. If you are evaluating the same query repeatedly, it may be more efficient to 060 parse it once and reuse the Evaluator. 061 062 <p>A query may contain at most {@value #MaxQueryComplexity} combinators, node selectors, and {@code :has()}, 063 {@code :is()}, or {@code :not()} expressions in total, across all selector alternatives.</p> 064 065 @param query CSS query 066 @return Evaluator 067 @see Selector selector query syntax 068 @throws Selector.SelectorParseException if the CSS query is invalid or exceeds the complexity limit 069 */ 070 public static Evaluator parse(String query) { 071 return parse(query, QueryMode.current() == QueryMode.Web); 072 } 073 074 static Evaluator parse(String query, boolean web) { 075 return Evaluator.withMode(web, () -> { 076 try (QueryParser p = new QueryParser(query, web)) { 077 return p.parse(); 078 } catch (IllegalArgumentException e) { 079 throw new Selector.SelectorParseException(e.getMessage()); 080 } 081 }); 082 } 083 084 /** 085 Parse the query. We use this simplified expression of the grammar: 086 <pre> 087 SelectorGroup ::= Selector (',' Selector)* 088 Selector ::= [ Combinator ] SimpleSequence ( Combinator SimpleSequence )* 089 SimpleSequence ::= [ TypeSelector ] ( ID | Class | Attribute | Pseudo )* 090 Pseudo ::= ':' Name [ '(' SelectorGroup ')' ] 091 Combinator ::= S+ // descendant (whitespace) 092 | '>' // child 093 | '+' // adjacent sibling 094 | '~' // general sibling 095 </pre> 096 097 See <a href="https://www.w3.org/TR/selectors-4/#grammar">selectors-4</a> for the real thing 098 */ 099 Evaluator parse() { 100 Evaluator eval = parseSelectorGroup().evaluator(); 101 tq.consumeWhitespace(); 102 if (!tq.isEmpty()) 103 throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query, tq.remainder()); 104 return eval; 105 } 106 107 /** Parses an ordered group of comma-separated selectors. */ 108 private SelectorGroup parseSelectorGroup() { 109 List<ComplexSelector> selectors = new ArrayList<>(); 110 do { 111 selectors.add(parseSelector()); 112 } while (tq.matchChomp(',')); 113 return new SelectorGroup(selectors); 114 } 115 116 private ComplexSelector parseSelector() { 117 tq.consumeWhitespace(); 118 char leading = tq.matchesAny(Combinators) ? tq.consume() : 0; 119 if (leading != 0) consumeStructuralStep(); 120 List<Evaluator> compounds = Collections.singletonList(parseSimpleSequence()); 121 List<Character> combinators = Collections.emptyList(); 122 while (true) { 123 char combinator = tq.consumeWhitespace() ? ' ' : 0; 124 if (tq.matchesAny(Combinators)) combinator = tq.consume(); 125 else if (tq.matchesAny(SequenceEnders) || tq.isEmpty()) break; 126 if (combinator == 0) break; 127 consumeStructuralStep(); 128 if (combinators.isEmpty()) { 129 compounds = new ArrayList<>(compounds); 130 combinators = new ArrayList<>(); 131 } 132 combinators.add(combinator); 133 compounds.add(parseSimpleSequence()); 134 } 135 return new ComplexSelector(compounds, combinators, leading); 136 } 137 138 /** Stores selector parts and relationships, such as {@code div.product > a}, for ordinary queries and {@code :has()}. */ 139 private final class ComplexSelector { 140 final List<Evaluator> compounds; 141 final List<Character> combinators; 142 final char leadingCombinator; 143 144 ComplexSelector(List<Evaluator> compounds, List<Character> combinators, char leading) { 145 this.compounds = compounds; 146 this.combinators = combinators; 147 leadingCombinator = leading; 148 } 149 150 Evaluator evaluator() { 151 return evaluator(leadingCombinator != 0 ? new StructuralEvaluator.QueryRoot() : null); 152 } 153 154 Evaluator relativeEvaluator() { 155 if (compounds.size() == 1 && (leadingCombinator != '+' || !wantsNodes())) 156 return compounds.get(0); // traversal already guarantees the leading relationship 157 return evaluator(leadingCombinator != 0 || web ? new StructuralEvaluator.RelativeAnchor() : null); 158 } 159 160 private Evaluator evaluator(@Nullable Evaluator anchor) { 161 Evaluator left = compounds.get(0); 162 if (anchor != null) left = combinator(anchor, leadingCombinator == 0 ? ' ' : leadingCombinator, left); 163 for (int i = 0; i < combinators.size(); i++) 164 left = combinator(left, combinators.get(i), compounds.get(i + 1)); 165 return left; 166 } 167 168 boolean wantsNodes() { 169 for (Evaluator compound : compounds) if (compound.wantsNodes()) return true; 170 return false; 171 } 172 173 @Override public String toString() { 174 StringBuilder out = new StringBuilder(); 175 if (leadingCombinator != 0) out.append(leadingCombinator).append(' '); 176 out.append(compounds.get(0)); 177 for (int i = 0; i < combinators.size(); i++) { 178 char relation = combinators.get(i); 179 out.append(' '); 180 if (relation != ' ') out.append(relation).append(' '); 181 out.append(compounds.get(i + 1)); 182 } 183 return out.toString(); 184 } 185 } 186 187 /** A parsed selector group that can be compiled or rendered in source order. */ 188 private static final class SelectorGroup { 189 final List<ComplexSelector> selectors; 190 191 /** Creates a group from its selectors in source order. */ 192 SelectorGroup(List<ComplexSelector> selectors) { 193 this.selectors = selectors; 194 } 195 196 /** Build the selectors into a single evaluator. */ 197 Evaluator evaluator() { 198 Evaluator first = selectors.get(0).evaluator(); 199 if (selectors.size() == 1) 200 return first; 201 202 ArrayList<Evaluator> evaluators = new ArrayList<>(selectors.size()); 203 // preserve a leading Or's flat evaluation order, e.g. the two alternatives compiled for *|tag 204 if (first instanceof CombiningEvaluator.Or) 205 evaluators.addAll(((CombiningEvaluator.Or) first).evaluators); 206 else 207 evaluators.add(first); 208 for (int i = 1; i < selectors.size(); i++) 209 evaluators.add(selectors.get(i).evaluator()); 210 return new CombiningEvaluator.Or(evaluators); 211 } 212 213 @Override public String toString() { 214 return StringUtil.join(selectors, ", "); 215 } 216 } 217 218 Evaluator parseSimpleSequence() { 219 // SimpleSequence ::= TypeSelector? ( Hash | Class | Pseudo )* 220 Evaluator first = null; 221 tq.consumeWhitespace(); 222 223 // one optional type selector 224 if (tq.matchesWord() || tq.matches("*|")) 225 first = byTag(); 226 else if (tq.matchChomp('*')) 227 first = new Evaluator.AllElements(); 228 229 // use the first subclass when there is no type selector 230 if (first == null) 231 first = parseSubclass(); 232 233 if (first == null) 234 throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query, tq.remainder()); 235 236 return parseRemainingSubclasses(first); 237 } 238 239 /** Parses and combines any subclasses that follow the first evaluator. */ 240 private Evaluator parseRemainingSubclasses(Evaluator first) { 241 Evaluator next = parseSubclass(); 242 if (next == null) 243 return first; 244 245 ArrayList<Evaluator> evaluators = new ArrayList<>(4); 246 evaluators.add(first); 247 do { 248 evaluators.add(next); 249 } while ((next = parseSubclass()) != null); 250 return new CombiningEvaluator.And(evaluators); 251 } 252 253 static Evaluator combinator(Evaluator left, char combinator, Evaluator right) { 254 switch (combinator) { 255 case '>': 256 ImmediateParentRun run = left instanceof ImmediateParentRun ? 257 (ImmediateParentRun) left : new ImmediateParentRun(left); 258 run.add(right); 259 return run; 260 case ' ': 261 return new CombiningEvaluator.And(new StructuralEvaluator.Ancestor(left), right); 262 case '+': 263 return new CombiningEvaluator.And(new StructuralEvaluator.ImmediatePreviousSibling(left), right); 264 case '~': 265 return new CombiningEvaluator.And(new StructuralEvaluator.PreviousSibling(left), right); 266 default: 267 throw new Selector.SelectorParseException("Unknown combinator '%s'", combinator); 268 } 269 } 270 271 @Nullable Evaluator parseSubclass() { 272 // Subclass: ID | Class | Attribute | Pseudo 273 if (tq.matchChomp('#')) return byId(); 274 else if (tq.matchChomp('.')) return byClass(); 275 else if (tq.matches('[')) return byAttribute(); 276 else if (tq.matchChomp("::")) return parseNodeSelector(); // ::comment etc 277 else if (tq.matchChomp(':')) return parsePseudoSelector(); 278 else return null; 279 } 280 281 private Evaluator parsePseudoSelector() { 282 final String pseudo = tq.consumeCssIdentifier(); 283 switch (pseudo) { 284 case "lt": 285 return new Evaluator.IndexLessThan(consumeIndex()); 286 case "gt": 287 return new Evaluator.IndexGreaterThan(consumeIndex()); 288 case "eq": 289 return new Evaluator.IndexEquals(consumeIndex()); 290 case "has": 291 return has(); 292 case "is": 293 return is(); 294 case "contains": 295 return contains(false); 296 case "containsOwn": 297 return contains(true); 298 case "containsWholeText": 299 return containsWholeText(false); 300 case "containsWholeOwnText": 301 return containsWholeText(true); 302 case "containsData": 303 return containsData(); 304 case "matches": 305 return matches(false); 306 case "matchesAttr": 307 return matchesAttr(); 308 case "matchesOwn": 309 return matches(true); 310 case "matchesWholeText": 311 return matchesWholeText(false); 312 case "matchesWholeOwnText": 313 return matchesWholeText(true); 314 case "not": 315 return not(); 316 case "nth-child": 317 return cssNthChild(false, false); 318 case "nth-last-child": 319 return cssNthChild(true, false); 320 case "nth-of-type": 321 return cssNthChild(false, true); 322 case "nth-last-of-type": 323 return cssNthChild(true, true); 324 case "first-child": 325 return new Evaluator.IsFirstChild(); 326 case "last-child": 327 return new Evaluator.IsLastChild(); 328 case "first-of-type": 329 return new Evaluator.IsFirstOfType(); 330 case "last-of-type": 331 return new Evaluator.IsLastOfType(); 332 case "only-child": 333 return new Evaluator.IsOnlyChild(); 334 case "only-of-type": 335 return new Evaluator.IsOnlyOfType(); 336 case "empty": 337 return new Evaluator.IsEmpty(); 338 case "blank": 339 return new NodeEvaluator.BlankValue(); 340 case "scope": 341 return new Evaluator.IsScope(); 342 case "root": 343 return new Evaluator.IsRoot(); 344 case "matchText": { 345 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 346 } 347 default: 348 throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query, tq.remainder()); 349 } 350 } 351 352 /** Parses a node selector and its subclasses in node-value context. */ 353 private Evaluator parseNodeSelector() { 354 consumeStructuralStep(); 355 final String pseudo = tq.consumeCssIdentifier(); 356 Evaluator nodeType; 357 switch (pseudo) { 358 case "node": 359 nodeType = new NodeEvaluator.InstanceType(Node.class, pseudo); 360 break; 361 case "leafnode": 362 nodeType = new NodeEvaluator.InstanceType(LeafNode.class, pseudo); 363 break; 364 case "text": 365 nodeType = new NodeEvaluator.InstanceType(TextNode.class, pseudo); 366 break; 367 case "comment": 368 nodeType = new NodeEvaluator.InstanceType(Comment.class, pseudo); 369 break; 370 case "data": 371 nodeType = new NodeEvaluator.InstanceType(DataNode.class, pseudo); 372 break; 373 case "cdata": 374 nodeType = new NodeEvaluator.InstanceType(CDataNode.class, pseudo); 375 break; 376 case "pi": 377 nodeType = piSelector(); 378 break; 379 default: 380 throw new Selector.SelectorParseException( 381 "Could not parse query '%s': unknown node type '::%s'", query, pseudo); 382 } 383 384 // nested selectors can enter node context again, e.g. ::comment:not(::text):contains(foo) 385 // restore the caller's context even if parsing a subclass throws 386 boolean previousNodeContext = inNodeContext; 387 inNodeContext = true; 388 try { 389 return parseRemainingSubclasses(nodeType); 390 } finally { 391 inNodeContext = previousNodeContext; 392 } 393 } 394 395 /** Parses an optional processing-instruction target. */ 396 private Evaluator piSelector() { 397 if (!tq.matches('(')) 398 return new NodeEvaluator.InstanceType(ProcessingInstruction.class, "pi"); 399 400 String argument = trimAsciiWhitespace(consumeParens()); 401 Validate.notEmpty(argument, "::pi target must not be empty"); 402 try (TokenQueue targetQueue = new TokenQueue(argument)) { 403 String target = targetQueue.consumeCssIdentifier(); 404 targetQueue.consumeWhitespace(); 405 Validate.isTrue(targetQueue.isEmpty(), "::pi target must be a CSS identifier"); 406 return new NodeEvaluator.ProcessingInstructionTarget(target); 407 } 408 } 409 410 private Evaluator byId() { 411 String id = tq.consumeCssIdentifier(); 412 Validate.notEmpty(id); 413 return new Evaluator.Id(id); 414 } 415 416 private Evaluator byClass() { 417 String className = tq.consumeCssIdentifier(); 418 Validate.notEmpty(className); 419 return new Evaluator.Class(className.trim()); 420 } 421 422 private Evaluator byTag() { 423 String tagName = tq.consumeElementSelector(); 424 Validate.notEmpty(tagName); 425 426 // namespaces: 427 if (tagName.startsWith("*|")) { // namespaces: wildcard match equals(tagName) or ending in ":"+tagName 428 String plainTag = tagName.substring(2); // strip *| 429 return new CombiningEvaluator.Or( 430 new Evaluator.Tag(plainTag), 431 new Evaluator.TagEndsWith(":" + plainTag) 432 ); 433 } else if (tagName.endsWith("|*")) { // ns|* 434 String ns = tagName.substring(0, tagName.length() - 2) + ":"; // strip |*, to ns: 435 return new Evaluator.TagStartsWith(ns); 436 } else if (tagName.contains("|")) { // flip "abc|def" to "abc:def" 437 tagName = tagName.replace("|", ":"); 438 } 439 440 return new Evaluator.Tag(tagName); 441 } 442 443 private Evaluator byAttribute() { 444 if (!web) return LegacyQueryParser.attribute(tq, query); 445 String attribute = peekCssAttribute(query, tq.pos(), true); 446 try (TokenQueue css = new TokenQueue(attribute)) { 447 Evaluator eval = cssAttribute(css, true); 448 consumeAttribute(tq, attribute); 449 return eval; 450 } 451 } 452 453 /** Reads the contents of {@code [...]} without advancing the query, allowing legacy parsing to retry. */ 454 static String peekCssAttribute(String query, int offset, boolean extendedValues) { 455 int start = offset + 1; 456 char quote = 0; 457 int depth = 1; 458 for (int pos = start; pos < query.length(); pos++) { 459 char c = query.charAt(pos); 460 if (c == '\\') { 461 pos++; 462 continue; 463 } 464 if (quote != 0) { 465 if (c == quote) quote = 0; 466 } else if (c == '\'' || c == '"') { 467 quote = c; 468 } else if (extendedValues && c == '[') { 469 depth++; // preserve unquoted values such as http://[::1]/ 470 } else if (c == ']' && --depth == 0) { 471 return query.substring(start, pos); 472 } 473 } 474 throw new Selector.SelectorParseException("Did not find balanced marker at '%s'", query.substring(start)); 475 } 476 477 static void consumeAttribute(TokenQueue tq, String attribute) { 478 for (int i = 0; i < attribute.length() + 2; i++) tq.advance(); 479 } 480 481 /** 482 Parses an attribute selector. With {@code extendedValues} false, unquoted values must be CSS identifiers 483 so legacy values ending in {@code i} or {@code s} are not mistaken for case flags. 484 */ 485 static Evaluator cssAttribute(TokenQueue cq, boolean extendedValues) { 486 cq.consumeWhitespace(); 487 boolean prefix = cq.matchChomp('^'); 488 if (prefix && cq.isEmpty()) return new Evaluator.AttributeStarting(""); 489 String key = consumeAttributeName(cq, cq.matchChomp('*') ? "*" : cq.consumeCssIdentifier()); 490 cq.consumeWhitespace(); 491 if (cq.isEmpty()) return prefix ? new Evaluator.AttributeStarting(key) : key.equals("*") ? 492 new Evaluator.AttributeStarting("") : new Evaluator.Attribute(key); 493 Validate.isFalse(prefix || key.equals("*"), "Attribute value comparison requires an attribute name"); 494 AttributeOperator operator = null; 495 for (AttributeOperator candidate : AttributeOperators) { 496 if (cq.matchChomp(candidate.symbol)) { operator = candidate; break; } 497 } 498 Validate.notNull(operator, "Attribute operator expected"); 499 AttributeValue operand = consumeAttributeValue(cq, extendedValues); 500 String value = operand.value; 501 CaseSensitivity flag = operand.flag; 502 switch (operator) { 503 case Equals: return new Evaluator.AttributeWithValue(key, value, flag); 504 case NotEquals: return new Evaluator.AttributeWithValueNot(key, value, flag); 505 case StartsWith: return new Evaluator.AttributeWithValueStarting(key, value, flag); 506 case EndsWith: return new Evaluator.AttributeWithValueEnding(key, value, flag); 507 case Contains: return new Evaluator.AttributeWithValueContaining(key, value, flag); 508 case ContainsToken: return new Evaluator.AttributeWithValueContainingToken(key, value, flag); 509 case DashMatch: return new Evaluator.AttributeWithValueDashPrefix(key, value, flag); 510 default: throw new IllegalArgumentException("Unknown attribute operator: " + operator); 511 } 512 } 513 514 private static AttributeValue consumeAttributeValue(TokenQueue cq, boolean extendedValues) { 515 cq.consumeWhitespace(); 516 boolean quoted = cq.matches('\'') || cq.matches('"'); 517 String value; 518 CaseSensitivity flag = CaseSensitivity.Default; 519 if (extendedValues && !quoted) { 520 StringBuilder out = new StringBuilder(); 521 String whitespace = ""; 522 while (!cq.isEmpty()) { 523 String part = cq.consumeCssValue(true); 524 StringBuilder nextWhitespace = new StringBuilder(); 525 while (cq.matchesWhitespace()) nextWhitespace.append(cq.consume()); 526 CaseSensitivity nextFlag = attributeCase(part); 527 if (cq.isEmpty() && out.length() != 0 && nextFlag != CaseSensitivity.Default) 528 flag = nextFlag; 529 else 530 out.append(whitespace).append(part); 531 whitespace = nextWhitespace.toString(); 532 } 533 value = out.toString(); 534 } else { 535 Validate.isFalse(cq.isEmpty(), "Attribute value expected"); 536 value = cq.consumeCssValue(); 537 boolean separated = cq.consumeWhitespace(); 538 if (!cq.isEmpty()) { 539 Validate.isTrue(quoted || separated, "Attribute flag must follow a quoted value or whitespace"); 540 String name = cq.consumeCssIdentifier(); 541 flag = attributeCase(name); 542 Validate.isTrue(flag != CaseSensitivity.Default, "Unknown attribute case flag: " + asciiLowerCase(name)); 543 cq.consumeWhitespace(); 544 } 545 } 546 Validate.isTrue(cq.isEmpty(), "Unexpected token after attribute value"); 547 return new AttributeValue(value, flag); 548 } 549 550 private static final class AttributeValue { 551 final String value; 552 final CaseSensitivity flag; 553 554 AttributeValue(String value, CaseSensitivity flag) { 555 this.value = value; 556 this.flag = flag; 557 } 558 } 559 560 private static CaseSensitivity attributeCase(String value) { 561 String name = asciiLowerCase(value); 562 return name.equals("i") ? CaseSensitivity.Insensitive : 563 name.equals("s") ? CaseSensitivity.Sensitive : CaseSensitivity.Default; 564 } 565 566 /** 567 Allows attribute names containing a colon, including {@code abs:href}. 568 */ 569 private static String consumeAttributeName(TokenQueue cq, String key) { 570 Validate.notEmpty(key, "Attribute name expected"); 571 if (cq.matchChomp(':')) { 572 String local = cq.consumeCssIdentifier(); 573 Validate.isFalse(key.equals("abs") && local.isEmpty(), "Absolute attribute key must have a name"); 574 Validate.notEmpty(local, "Attribute name expected"); 575 key += ":" + local; 576 } 577 return key; 578 } 579 580 /** 581 Parses {@code :matchesAttr()}, leaving regex escapes intact. 582 */ 583 private Evaluator matchesAttr() { 584 Validate.isTrue(tq.matchChomp('('), ":matchesAttr requires an attribute name and regex"); 585 tq.consumeWhitespace(); 586 String key = consumeAttributeName(tq, tq.consumeCssIdentifier()); 587 tq.consumeWhitespace(); 588 Validate.isTrue(tq.matchChomp(','), ":matchesAttr requires an attribute name and regex"); 589 tq.consumeWhitespace(); 590 String pattern = tq.chompBalancedRemainder('(', ')'); 591 Validate.notEmpty(pattern, ":matchesAttr requires a regex"); 592 return new Evaluator.AttributeWithValueMatching(key, Regex.compile(pattern)); 593 } 594 595 //pseudo selectors :first-child, :last-child, :nth-child, ... 596 private static final Pattern NthStepOffset = Pattern.compile("(([+-])?(\\d+)?)n(\\s*([+-])?\\s*\\d+)?", Pattern.CASE_INSENSITIVE); 597 private static final Pattern NthOffset = Pattern.compile("([+-])?(\\d+)"); 598 599 private Evaluator cssNthChild(boolean last, boolean ofType) { 600 String arg = asciiLowerCase(trimAsciiWhitespace(consumeParens())); // arg is like "odd", or "-n+2", within nth-child(odd) 601 final int step, offset; 602 if ("odd".equals(arg)) { 603 step = 2; 604 offset = 1; 605 } else if ("even".equals(arg)) { 606 step = 2; 607 offset = 0; 608 } else { 609 Matcher stepOffsetM, stepM; 610 if ((stepOffsetM = NthStepOffset.matcher(arg)).matches()) { 611 if (stepOffsetM.group(3) != null) // has digits, like 3n+2 or -3n+2 612 step = Integer.parseInt(stepOffsetM.group(1).replaceFirst("^\\+", "")); 613 else // no digits, might be like n+2, or -n+2. if group(2) == "-", it’s -1; 614 step = "-".equals(stepOffsetM.group(2)) ? -1 : 1; 615 offset = 616 stepOffsetM.group(4) != null ? Integer.parseInt(stepOffsetM.group(4).replaceFirst("^\\+", "")) : 0; 617 } else if ((stepM = NthOffset.matcher(arg)).matches()) { 618 step = 0; 619 offset = Integer.parseInt(stepM.group().replaceFirst("^\\+", "")); 620 } else { 621 throw new Selector.SelectorParseException("Could not parse nth-index '%s': unexpected format", arg); 622 } 623 } 624 625 return ofType 626 ? (last ? new Evaluator.IsNthLastOfType(step, offset) : new Evaluator.IsNthOfType(step, offset)) 627 : (last ? new Evaluator.IsNthLastChild(step, offset) : new Evaluator.IsNthChild(step, offset)); 628 } 629 630 /** Consumes a parenthesized argument, requiring its opening delimiter. */ 631 private String consumeParens() { 632 Validate.isTrue(tq.matches('('), "Expected '(' in query '" + query + "'"); 633 return tq.chompBalanced('(', ')'); 634 } 635 636 private int consumeIndex() { 637 String index = consumeParens().trim(); 638 Validate.isTrue(StringUtil.isNumeric(index), "Index must be numeric"); 639 return Integer.parseInt(index); 640 } 641 642 /** Parses {@code :has()}, grouping consecutive alternatives that can share a traversal. */ 643 private Evaluator has() { 644 String err = ":has() must have a selector"; 645 SelectorGroup group = parseNestedSelectorGroup(err); 646 return new HasEvaluator(group.toString(), planTraversals(group)); 647 } 648 649 /** 650 Groups alternatives that check the same elements. 651 For example, {@code :has(> a, > span)} checks the children once. 652 */ 653 private List<Traversal> planTraversals(SelectorGroup group) { 654 List<Traversal> traversals = new ArrayList<>(); 655 ArrayList<Evaluator> alternatives = new ArrayList<>(); 656 boolean startsAtSibling = false, descend = false, nextSiblingOnly = false; 657 for (ComplexSelector selector : group.selectors) { 658 char leading = selector.leadingCombinator; 659 boolean siblings = leading == '+' || leading == '~'; 660 boolean trees = selector.compounds.size() > 1 || !siblings && leading != '>'; 661 boolean nextOnly = leading == '+' && !selector.wantsNodes() && 662 !selector.combinators.contains('+') && !selector.combinators.contains('~'); 663 if (!alternatives.isEmpty() && 664 (siblings != startsAtSibling || trees != descend || nextOnly != nextSiblingOnly)) { 665 traversals.add(new Traversal(alternatives, startsAtSibling, descend, nextSiblingOnly)); 666 alternatives = new ArrayList<>(); 667 } 668 alternatives.add(selector.relativeEvaluator()); 669 startsAtSibling = siblings; 670 descend = trees; 671 nextSiblingOnly = nextOnly; 672 } 673 traversals.add(new Traversal(alternatives, startsAtSibling, descend, nextSiblingOnly)); 674 return traversals; 675 } 676 677 // pseudo selector :is() 678 private Evaluator is() { 679 return parseNested(StructuralEvaluator.Is::new, ":is() must have a selector"); 680 } 681 682 /** Parses a nested selector group and applies its compiled evaluator. */ 683 private Evaluator parseNested(Function<Evaluator, Evaluator> func, String err) { 684 return func.apply(parseNestedSelectorGroup(err).evaluator()); 685 } 686 687 /** Parses a non-empty selector group enclosed in parentheses. */ 688 private SelectorGroup parseNestedSelectorGroup(String err) { 689 Validate.isTrue(tq.matchChomp('('), err); 690 tq.consumeWhitespace(); 691 Validate.isTrue(!tq.isEmpty() && !tq.matches(')'), err); 692 consumeStructuralStep(); 693 SelectorGroup group = parseSelectorGroup(); 694 Validate.isTrue(tq.matchChomp(')'), err); 695 return group; 696 } 697 698 /** Bounds parser and evaluator recursion by counting structural steps across the whole query. */ 699 private void consumeStructuralStep() { 700 Validate.isTrue(++complexity <= MaxQueryComplexity, "Query complexity exceeds maximum of " + MaxQueryComplexity); 701 } 702 703 // pseudo selector :contains(text), containsOwn(text) 704 private Evaluator contains(boolean own) { 705 String query = own ? ":containsOwn" : ":contains"; 706 String searchText = TokenQueue.unescape(consumeParens()); 707 Validate.notEmpty(searchText, query + "(text) query must not be empty"); 708 709 if (inNodeContext) 710 return new NodeEvaluator.ContainsValue(searchText); 711 712 return own 713 ? new Evaluator.ContainsOwnText(searchText) 714 : new Evaluator.ContainsText(searchText); 715 } 716 717 private Evaluator containsWholeText(boolean own) { 718 String query = own ? ":containsWholeOwnText" : ":containsWholeText"; 719 String searchText = TokenQueue.unescape(consumeParens()); 720 Validate.notEmpty(searchText, query + "(text) query must not be empty"); 721 return own 722 ? new Evaluator.ContainsWholeOwnText(searchText) 723 : new Evaluator.ContainsWholeText(searchText); 724 } 725 726 // pseudo selector :containsData(data) 727 private Evaluator containsData() { 728 String searchText = TokenQueue.unescape(consumeParens()); 729 Validate.notEmpty(searchText, ":containsData(text) query must not be empty"); 730 return new Evaluator.ContainsData(searchText); 731 } 732 733 // :matches(regex), matchesOwn(regex) 734 private Evaluator matches(boolean own) { 735 String query = own ? ":matchesOwn" : ":matches"; 736 Regex pattern = consumeRegex(query); 737 738 if (inNodeContext) 739 return new NodeEvaluator.MatchesValue(pattern); 740 741 return own 742 ? new Evaluator.MatchesOwn(pattern) 743 : new Evaluator.Matches(pattern); 744 } 745 746 // :matchesWholeText(regex), matchesWholeOwnText(regex) 747 private Evaluator matchesWholeText(boolean own) { 748 String query = own ? ":matchesWholeOwnText" : ":matchesWholeText"; 749 Regex pattern = consumeRegex(query); 750 return own 751 ? new Evaluator.MatchesWholeOwnText(pattern) 752 : new Evaluator.MatchesWholeText(pattern); 753 } 754 755 /** Consumes and compiles a non-empty regex argument, preserving its escapes. */ 756 private Regex consumeRegex(String pseudo) { 757 String regex = consumeParens(); 758 Validate.notEmpty(regex, pseudo + "(regex) query must not be empty"); 759 return Regex.compile(regex); 760 } 761 762 /** Parses the selector group negated by {@code :not()}. */ 763 private Evaluator not() { 764 return parseNested(StructuralEvaluator.Not::new, ":not(selector) subselect must not be empty"); 765 } 766 767 @Override 768 public String toString() { 769 return query; 770 } 771 772 @Override 773 public void close() { 774 tq.close(); 775 } 776}