001package org.jsoup.parser; 002 003import org.jsoup.internal.StringUtil; 004import org.jsoup.helper.Validate; 005 006/** 007 A character reader with helpers focusing on parsing CSS selectors. Used internally by jsoup. API subject to changes. 008 */ 009 010public class TokenQueue implements AutoCloseable { 011 private static final char Esc = '\\'; // escape char for chomp balanced. 012 private static final char Hyphen_Minus = '-'; 013 private static final char Unicode_Null = '\u0000'; 014 private static final char Replacement = '\uFFFD'; 015 016 private final CharacterReader reader; 017 018 /** 019 Create a new TokenQueue. 020 @param data string of data to back queue. 021 */ 022 public TokenQueue(String data) { 023 reader = new CharacterReader(data); 024 } 025 026 /** 027 Is the queue empty? 028 @return true if no data left in queue. 029 */ 030 public boolean isEmpty() { 031 return reader.isEmpty(); 032 } 033 034 /** 035 Get the current zero-based character offset in the input. 036 037 @since 1.24.1 038 */ 039 public int pos() { 040 return reader.pos(); 041 } 042 043 /** 044 Consume one character off queue. 045 @return first character on queue. 046 */ 047 public char consume() { 048 return reader.consume(); 049 } 050 051 /** 052 Drops the next character off the queue. 053 */ 054 public void advance() { 055 if (!isEmpty()) reader.advance(); 056 } 057 058 char current() { 059 return reader.current(); 060 } 061 062 /** 063 Tests if the next characters on the queue match the sequence, case-insensitively. 064 @param seq String to check queue for. 065 @return true if the next characters match. 066 */ 067 public boolean matches(String seq) { 068 return reader.matchesIgnoreCase(seq); 069 } 070 071 /** Tests if the next character on the queue matches the character, case-sensitively. */ 072 public boolean matches(char c) { 073 return reader.matches(c); 074 } 075 076 /** 077 Tests if the next characters match any of the sequences, case-<b>sensitively</b>. 078 @param seq list of chars to case-sensitively check for 079 @return true of any matched, false if none did 080 */ 081 public boolean matchesAny(char... seq) { 082 return reader.matchesAny(seq); 083 } 084 085 /** 086 If the queue case-insensitively matches the supplied string, consume it off the queue. 087 @param seq String to search for, and if found, remove from queue. 088 @return true if found and removed, false if not found. 089 */ 090 public boolean matchChomp(String seq) { 091 return reader.matchConsumeIgnoreCase(seq); 092 } 093 094 /** If the queue matches the supplied (case-sensitive) character, consume it off the queue. */ 095 public boolean matchChomp(char c) { 096 if (reader.matches(c)) { 097 consume(); 098 return true; 099 } 100 return false; 101 } 102 103 /** 104 Tests if queue starts with a whitespace character. 105 @return if starts with whitespace 106 */ 107 public boolean matchesWhitespace() { 108 return StringUtil.isWhitespace(reader.current()); 109 } 110 111 /** 112 Test if the queue matches a tag word character (letter or digit). 113 @return if matches a word character 114 */ 115 public boolean matchesWord() { 116 return Character.isLetterOrDigit(reader.current()); 117 } 118 119 /** 120 Consumes the supplied sequence of the queue, case-insensitively. If the queue does not start with the supplied 121 sequence, will throw an illegal state exception -- but you should be running match() against that condition. 122 123 @param seq sequence to remove from head of queue. 124 */ 125 public void consume(String seq) { 126 boolean found = reader.matchConsumeIgnoreCase(seq); 127 if (!found) throw new IllegalStateException("Queue did not match expected sequence"); 128 } 129 130 /** 131 Pulls a string off the queue, up to but exclusive of the match sequence, or to the queue running out. 132 @param seq String to end on (and not include in return, but leave on queue). <b>Case-sensitive.</b> 133 @return The matched data consumed from queue. 134 */ 135 public String consumeTo(String seq) { 136 return reader.consumeTo(seq); 137 } 138 139 /** 140 Consumes to the first sequence provided, or to the end of the queue. Leaves the terminator on the queue. 141 @param seq any number of terminators to consume to. <b>Case-insensitive.</b> 142 @return consumed string 143 */ 144 public String consumeToAny(String... seq) { 145 StringBuilder sb = StringUtil.borrowBuilder(); 146 OUT: while (!isEmpty()) { 147 for (String s : seq) { 148 if (reader.matchesIgnoreCase(s)) break OUT; 149 } 150 sb.append(consume()); 151 } 152 return StringUtil.releaseBuilder(sb); 153 } 154 155 /** 156 Pulls a balanced string off the queue. E.g. if queue is "(one (two) three) four", (,) will return "one (two) three", 157 and leave " four" on the queue. Unbalanced openers and closers can be quoted (with ' or ") or escaped (with \). 158 Those escapes will be left in the returned string, which is suitable for regexes (where we need to preserve the 159 escape), but unsuitable for contains text strings; use unescape for that. 160 161 @param open opener 162 @param close closer 163 @return data matched from the queue 164 */ 165 public String chompBalanced(char open, char close) { 166 return chompBalanced(open, close, 0); 167 } 168 169 /** 170 Consume the remainder of a group whose opening delimiter has already been consumed. The closing delimiter is 171 consumed but excluded from the result. Quotes and escapes follow {@link #chompBalanced(char, char)}. 172 If the group is unbalanced, restore the queue position. 173 174 @param open opener 175 @param close closer 176 @return remaining group contents, excluding the closing delimiter 177 @throws IllegalArgumentException if the group is unbalanced 178 @since 1.24.1 179 */ 180 public String chompBalancedRemainder(char open, char close) { 181 return chompBalanced(open, close, 1); 182 } 183 184 private String chompBalanced(char open, char close, int depth) { 185 StringBuilder accum = StringUtil.borrowBuilder(); 186 char prev = 0; 187 boolean inSingle = false; 188 boolean inDouble = false; 189 boolean inRegexQE = false; // regex \Q .. \E escapes from Pattern.quote() 190 reader.mark(); // mark the initial position to restore if needed 191 192 do { 193 if (isEmpty()) break; 194 char c = consume(); 195 if (prev == Esc) { 196 if (c == 'Q') inRegexQE = true; 197 else if (c == 'E') inRegexQE = false; 198 accum.append(c); 199 } else { 200 if (c == '\'' && c != open && !inDouble) inSingle = !inSingle; 201 else if (c == '"' && c != open && !inSingle) inDouble = !inDouble; 202 203 if (inSingle || inDouble || inRegexQE) { 204 accum.append(c); 205 } else if (c == open) { 206 depth++; 207 if (depth > 1) accum.append(c); // don't include the outer match pair in the return 208 } else if (c == close) { 209 depth--; 210 if (depth > 0) accum.append(c); 211 } else { 212 accum.append(c); 213 } 214 } 215 prev = c; 216 } while (depth > 0); 217 218 String out = StringUtil.releaseBuilder(accum); 219 if (depth > 0) {// ran out of queue before seeing enough ) 220 reader.rewindToMark(); // restore position if we don't have a balanced string 221 Validate.fail("Did not find balanced marker at '" + out + "'"); 222 } 223 return out; 224 } 225 226 /** 227 * Unescape a \ escaped string. 228 * @param in backslash escaped string 229 * @return unescaped string 230 */ 231 public static String unescape(String in) { 232 if (in.indexOf(Esc) == -1) return in; 233 234 StringBuilder out = StringUtil.borrowBuilder(); 235 char last = 0; 236 for (char c : in.toCharArray()) { 237 if (c == Esc) { 238 if (last == Esc) { 239 out.append(c); 240 c = 0; 241 } 242 } 243 else 244 out.append(c); 245 last = c; 246 } 247 return StringUtil.releaseBuilder(out); 248 } 249 250 /** 251 Given a CSS identifier (such as a tag, ID, or class), escape any CSS special characters that would otherwise not be 252 valid in a selector. 253 254 @see <a href="https://www.w3.org/TR/cssom-1/#serialize-an-identifier">CSS Object Model, serialize an identifier</a> 255 */ 256 public static String escapeCssIdentifier(String in) { 257 if (in.isEmpty()) return in; 258 259 StringBuilder out = StringUtil.borrowBuilder(); 260 TokenQueue q = new TokenQueue(in); 261 262 char firstChar = q.current(); 263 if (firstChar == Hyphen_Minus) { 264 q.advance(); 265 if (q.isEmpty()) { 266 // If the character is the first character and is a "-" (U+002D), and there is no second character, then 267 // the escaped character. 268 appendEscaped(out, Hyphen_Minus); 269 } else { 270 out.append(Hyphen_Minus); 271 272 char secondChar = q.current(); 273 if (StringUtil.isDigit(secondChar)) { 274 // If the character is the second character and is in the range [0-9] (U+0030 to U+0039) and the 275 // first character is a "-" (U+002D), then the character escaped as code point. 276 appendEscapedCodepoint(out, q.consume()); 277 } 278 } 279 } else if (StringUtil.isDigit(firstChar)) { 280 // If the character is the first character and is in the range [0-9] (U+0030 to U+0039), then the character 281 // escaped as code point. 282 appendEscapedCodepoint(out, q.consume()); 283 } 284 285 while (!q.isEmpty()) { 286 // Note: It's fine to iterate on chars because non-ASCII characters are never escaped. So surrogate pairs 287 // are kept intact. 288 char c = q.consume(); 289 if (c == Unicode_Null) { 290 // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER (U+FFFD). 291 out.append(Replacement); 292 } else if (c <= '\u001F' || c == '\u007F') { 293 // If the character is in the range [\1-\1f] (U+0001 to U+001F) or is U+007F, then the character 294 // escaped as code point. 295 appendEscapedCodepoint(out, c); 296 } else if (isIdent(c)) { 297 // If the character is not handled by one of the above rules and is greater than or equal to U+0080, 298 // is "-" (U+002D) or "_" (U+005F), or is in one of the ranges [0-9] (U+0030 to U+0039), 299 // [A-Z] (U+0041 to U+005A), or [a-z] (U+0061 to U+007A), then the character itself. 300 out.append(c); 301 } else { 302 // Otherwise, the escaped character. 303 appendEscaped(out, c); 304 } 305 } 306 307 q.close(); 308 return StringUtil.releaseBuilder(out); 309 } 310 311 private static void appendEscaped(StringBuilder out, char c) { 312 out.append(Esc).append(c); 313 } 314 315 private static void appendEscapedCodepoint(StringBuilder out, char c) { 316 out.append(Esc).append(Integer.toHexString(c)).append(' '); 317 } 318 319 /** 320 * Pulls the next run of whitespace characters of the queue. 321 * @return Whether consuming whitespace or not 322 */ 323 public boolean consumeWhitespace() { 324 boolean seen = false; 325 while (matchesWhitespace()) { 326 advance(); 327 seen = true; 328 } 329 return seen; 330 } 331 332 /** 333 * Consume a CSS element selector (tag name, but | instead of : for namespaces (or *| for wildcard namespace), to not conflict with :pseudo selects). 334 * 335 * @return tag name 336 */ 337 public String consumeElementSelector() { 338 return consumeEscapedCssIdentifier(ElementSelectorChars); 339 } 340 private static final char[] ElementSelectorChars = {'*', '|', '_', '-'}; 341 342 /** 343 Consume a CSS identifier (ID or class) off the queue. 344 <p>Note: For backwards compatibility this method supports improperly formatted CSS identifiers, e.g. {@code 1} instead 345 of {@code \31}.</p> 346 347 @return The unescaped identifier. 348 @throws IllegalArgumentException if an invalid escape sequence was found. Afterward, the state of the TokenQueue 349 is undefined. 350 @see <a href="https://www.w3.org/TR/css-syntax-3/#consume-name">CSS Syntax Module Level 3, Consume an ident sequence</a> 351 @see <a href="https://www.w3.org/TR/css-syntax-3/#typedef-ident-token">CSS Syntax Module Level 3, ident-token</a> 352 */ 353 public String consumeCssIdentifier() { 354 if (isEmpty()) throw new IllegalArgumentException("CSS identifier expected, but end of input found"); 355 356 // Fast path for CSS identifiers that don't contain escape sequences. 357 String identifier = reader.consumeMatching(TokenQueue::isIdent); 358 char c = current(); 359 if (c != Esc && c != Unicode_Null) { 360 // If we didn't end on an Esc or a Null, we consumed the whole identifier 361 return identifier; 362 } 363 364 // An escape sequence was found. Use a StringBuilder to store the decoded CSS identifier. 365 StringBuilder out = StringUtil.borrowBuilder(); 366 if (!identifier.isEmpty()) { 367 // Copy the CSS identifier up to the first escape sequence. 368 out.append(identifier); 369 } 370 371 while (!isEmpty()) { 372 c = current(); 373 if (isIdent(c)) { 374 out.append(consume()); 375 } else if (c == Unicode_Null) { 376 // https://www.w3.org/TR/css-syntax-3/#input-preprocessing 377 advance(); 378 out.append(Replacement); 379 } else if (c == Esc) { 380 reader.mark(); // keep the backslash available if newline lookahead reaches a buffer boundary 381 advance(); 382 if (!isEmpty() && isNewline(current())) { 383 // Not a valid escape sequence. This is treated as the end of the CSS identifier. 384 reader.rewindToMark(); 385 break; 386 } else { 387 reader.unmark(); 388 consumeCssEscapeSequenceInto(out); 389 } 390 } else { 391 break; 392 } 393 } 394 return StringUtil.releaseBuilder(out); 395 } 396 397 /** 398 Consume a quoted CSS string or an unquoted CSS identifier, resolving escapes and removing surrounding quotes. 399 400 @return the decoded value 401 @throws IllegalArgumentException if a valid value is not at the current position 402 @since 1.24.1 403 */ 404 public String consumeCssValue() { 405 if (matches('\'') || matches('"')) return consumeCssString(); 406 char first = current(); 407 Validate.isTrue(first == Unicode_Null || first == '\\' || first == '-' || first == '_' || Character.isLetter(first) || first >= 0x80, 408 "Attribute value must be a CSS identifier or quoted string"); 409 if (first == '-') { 410 reader.mark(); // keep the hyphen available while checking the next character 411 advance(); 412 char next = current(); 413 boolean valid = !isEmpty() && (next == Unicode_Null || next == '-' || next == '_' || next == Esc || Character.isLetter(next) || next >= 0x80); 414 reader.rewindToMark(); 415 Validate.isTrue(valid, "Attribute value must be a CSS identifier or quoted string"); 416 } 417 String value = consumeCssIdentifier(); 418 Validate.notEmpty(value, "Attribute value expected"); 419 return value; 420 } 421 422 /** 423 When {@code extended} is true, consume unquoted text up to unescaped whitespace or the end of input. 424 Resolve CSS escapes, but treat quotes as literal characters. Otherwise, use {@link #consumeCssValue()}. 425 426 @param extended whether to allow unquoted text beyond CSS identifiers 427 @return the decoded value 428 @since 1.24.1 429 */ 430 public String consumeCssValue(boolean extended) { 431 if (!extended) return consumeCssValue(); 432 StringBuilder out = new StringBuilder(); 433 while (!isEmpty() && !matchesWhitespace()) { 434 char c = consume(); 435 if (c == Esc) { 436 Validate.isFalse(!isEmpty() && isNewline(current()), "Invalid escape in unquoted attribute value"); 437 consumeCssEscapeSequenceInto(out); 438 } else { 439 out.append(c == Unicode_Null ? Replacement : c); 440 } 441 } 442 Validate.isTrue(out.length() != 0, "Attribute value expected"); 443 return out.toString(); 444 } 445 446 private String consumeCssString() { 447 char quote = consume(); 448 Validate.isTrue(quote == '\'' || quote == '"', "CSS string must start with a quote"); 449 StringBuilder out = new StringBuilder(); 450 while (!isEmpty()) { 451 char c = consume(); 452 if (c == quote) return out.toString(); 453 if (c == '\\') { 454 if (isEmpty()) break; 455 if (isNewline(current())) { 456 char newline = consume(); 457 if (newline == '\r' && current() == '\n') advance(); 458 } else consumeCssEscapeSequenceInto(out); 459 } else { 460 Validate.isFalse(isNewline(c), "Unescaped newline in CSS string"); 461 out.append(c == Unicode_Null ? Replacement : c); 462 } 463 } 464 throw new IllegalArgumentException("Unclosed CSS string"); 465 } 466 467 private void consumeCssEscapeSequenceInto(StringBuilder out) { 468 if (isEmpty()) { 469 out.append(Replacement); 470 return; 471 } 472 473 char firstEscaped = consume(); 474 if (!StringUtil.isHexDigit(firstEscaped)) { 475 out.append(firstEscaped); 476 } else { 477 reader.unconsume(); // put back the first hex digit 478 String hexString = reader.consumeMatching(StringUtil::isHexDigit, 6); // consume up to 6 hex digits 479 int codePoint; 480 try { 481 codePoint = Integer.parseInt(hexString, 16); 482 } catch (NumberFormatException e) { 483 throw new IllegalArgumentException("Invalid escape sequence: " + hexString, e); 484 } 485 if (isValidCodePoint(codePoint)) { 486 out.appendCodePoint(codePoint); 487 } else { 488 out.append(Replacement); 489 } 490 491 if (!isEmpty()) { 492 char c = current(); 493 if (c == '\r') { 494 // Since there's currently no input preprocessing, check for CRLF here. 495 // https://www.w3.org/TR/css-syntax-3/#input-preprocessing 496 advance(); 497 if (!isEmpty() && current() == '\n') advance(); 498 } else if (c == ' ' || c == '\t' || isNewline(c)) { 499 advance(); 500 } 501 } 502 } 503 } 504 505 // statics below specifically for CSS identifiers: 506 507 // https://www.w3.org/TR/css-syntax-3/#non-ascii-code-point 508 private static boolean isNonAscii(char c) { 509 return c >= '\u0080'; 510 } 511 512 // https://www.w3.org/TR/css-syntax-3/#ident-start-code-point 513 private static boolean isIdentStart(char c) { 514 return c == '_' || StringUtil.isAsciiLetter(c) || isNonAscii(c); 515 } 516 517 // https://www.w3.org/TR/css-syntax-3/#ident-code-point 518 private static boolean isIdent(char c) { 519 return c == Hyphen_Minus || StringUtil.isDigit(c) || isIdentStart(c); 520 } 521 522 // https://www.w3.org/TR/css-syntax-3/#newline 523 // Note: currently there's no preprocessing happening. 524 private static boolean isNewline(char c) { 525 return c == '\n' || c == '\r' || c == '\f'; 526 } 527 528 // https://www.w3.org/TR/css-syntax-3/#consume-an-escaped-code-point 529 private static boolean isValidCodePoint(int codePoint) { 530 return codePoint != 0 && Character.isValidCodePoint(codePoint) && !Character.isSurrogate((char) codePoint); 531 } 532 533 private static final char[] CssIdentifierChars = {'-', '_'}; 534 535 private String consumeEscapedCssIdentifier(char... matches) { 536 StringBuilder sb = StringUtil.borrowBuilder(); 537 while (!isEmpty()) { 538 char c = current(); 539 if (c == Esc) { 540 advance(); 541 if (!isEmpty()) sb.append(consume()); 542 else break; 543 } else if (matchesCssIdentifier(matches)) { 544 sb.append(c); 545 advance(); 546 } else { 547 break; 548 } 549 } 550 return StringUtil.releaseBuilder(sb); 551 } 552 553 private boolean matchesCssIdentifier(char... matches) { 554 return matchesWord() || reader.matchesAny(matches); 555 } 556 557 /** 558 Consume and return whatever is left on the queue. 559 @return remainder of queue. 560 */ 561 public String remainder() { 562 return reader.consumeToEnd(); 563 } 564 565 @Override 566 public String toString() { 567 return reader.toString(); 568 } 569 570 @Override 571 public void close() { 572 reader.close(); // releases buffer back to pool 573 } 574}