Repository: symfony/css-selector Branch: 8.1 Commit: f7c24bbef3be Files: 93 Total size: 213.2 KB Directory structure: gitextract_8h3d2omr/ ├── .gitattributes ├── .github/ │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ └── close-pull-request.yml ├── .gitignore ├── CHANGELOG.md ├── CssSelectorConverter.php ├── Exception/ │ ├── ExceptionInterface.php │ ├── ExpressionErrorException.php │ ├── InternalErrorException.php │ ├── ParseException.php │ └── SyntaxErrorException.php ├── LICENSE ├── Node/ │ ├── AbstractNode.php │ ├── AttributeNode.php │ ├── ClassNode.php │ ├── CombinedSelectorNode.php │ ├── ElementNode.php │ ├── FunctionNode.php │ ├── HashNode.php │ ├── MatchingNode.php │ ├── NegationNode.php │ ├── NodeInterface.php │ ├── PseudoNode.php │ ├── RelationNode.php │ ├── SelectorNode.php │ ├── Specificity.php │ └── SpecificityAdjustmentNode.php ├── Parser/ │ ├── Handler/ │ │ ├── CommentHandler.php │ │ ├── HandlerInterface.php │ │ ├── HashHandler.php │ │ ├── IdentifierHandler.php │ │ ├── NumberHandler.php │ │ ├── StringHandler.php │ │ └── WhitespaceHandler.php │ ├── Parser.php │ ├── ParserInterface.php │ ├── Reader.php │ ├── Shortcut/ │ │ ├── ClassParser.php │ │ ├── ElementParser.php │ │ ├── EmptyStringParser.php │ │ └── HashParser.php │ ├── Token.php │ ├── TokenStream.php │ └── Tokenizer/ │ ├── Tokenizer.php │ ├── TokenizerEscaping.php │ └── TokenizerPatterns.php ├── README.md ├── Tests/ │ ├── CssSelectorConverterTest.php │ ├── Node/ │ │ ├── AbstractNodeTestCase.php │ │ ├── AttributeNodeTest.php │ │ ├── ClassNodeTest.php │ │ ├── CombinedSelectorNodeTest.php │ │ ├── ElementNodeTest.php │ │ ├── FunctionNodeTest.php │ │ ├── HashNodeTest.php │ │ ├── MatchingNodeTest.php │ │ ├── NegationNodeTest.php │ │ ├── PseudoNodeTest.php │ │ ├── SelectorNodeTest.php │ │ ├── SpecificityAdjustmentNodeTest.php │ │ └── SpecificityTest.php │ ├── Parser/ │ │ ├── Handler/ │ │ │ ├── AbstractHandlerTestCase.php │ │ │ ├── CommentHandlerTest.php │ │ │ ├── HashHandlerTest.php │ │ │ ├── IdentifierHandlerTest.php │ │ │ ├── NumberHandlerTest.php │ │ │ ├── StringHandlerTest.php │ │ │ └── WhitespaceHandlerTest.php │ │ ├── ParserTest.php │ │ ├── ReaderTest.php │ │ ├── Shortcut/ │ │ │ ├── ClassParserTest.php │ │ │ ├── ElementParserTest.php │ │ │ ├── EmptyStringParserTest.php │ │ │ └── HashParserTest.php │ │ └── TokenStreamTest.php │ └── XPath/ │ ├── Fixtures/ │ │ ├── ids.html │ │ ├── lang.xml │ │ └── shakespear.html │ └── TranslatorTest.php ├── XPath/ │ ├── Extension/ │ │ ├── AbstractExtension.php │ │ ├── AttributeMatchingExtension.php │ │ ├── CombinationExtension.php │ │ ├── ExtensionInterface.php │ │ ├── FunctionExtension.php │ │ ├── HtmlExtension.php │ │ ├── NodeExtension.php │ │ ├── PseudoClassExtension.php │ │ └── RelationExtension.php │ ├── Translator.php │ ├── TranslatorInterface.php │ └── XPathExpr.php ├── composer.json └── phpunit.xml.dist ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ /Tests export-ignore /phpunit.xml.dist export-ignore /.git* export-ignore ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ Please do not submit any Pull Requests here. They will be closed. --- Please submit your PR here instead: https://github.com/symfony/symfony This repository is what we call a "subtree split": a read-only subset of that main repository. We're looking forward to your PR there! ================================================ FILE: .github/workflows/close-pull-request.yml ================================================ name: Close Pull Request on: pull_request_target: types: [opened] jobs: run: runs-on: ubuntu-latest steps: - uses: superbrothers/close-pull-request@v3 with: comment: | Thanks for your Pull Request! We love contributions. However, you should instead open your PR on the main repository: https://github.com/symfony/symfony This repository is what we call a "subtree split": a read-only subset of that main repository. We're looking forward to your PR there! ================================================ FILE: .gitignore ================================================ vendor/ composer.lock phpunit.xml ================================================ FILE: CHANGELOG.md ================================================ CHANGELOG ========= 8.1 --- * Add support for `:has()` 7.1 --- * Add support for `:is()` * Add support for `:where()` 6.3 --- * Add support for `:scope` 4.4.0 ----- * Added support for `*:only-of-type` 2.8.0 ----- * Added the `CssSelectorConverter` class as a non-static API for the component. * Deprecated the `CssSelector` static API of the component. 2.1.0 ----- * none ================================================ FILE: CssSelectorConverter.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector; use Symfony\Component\CssSelector\Parser\Shortcut\ClassParser; use Symfony\Component\CssSelector\Parser\Shortcut\ElementParser; use Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser; use Symfony\Component\CssSelector\Parser\Shortcut\HashParser; use Symfony\Component\CssSelector\XPath\Extension\HtmlExtension; use Symfony\Component\CssSelector\XPath\Translator; /** * CssSelectorConverter is the main entry point of the component and can convert CSS * selectors to XPath expressions. * * @author Christophe Coevoet */ class CssSelectorConverter { public static int $maxCachedItems = 1024; private Translator $translator; private array $cache; private static array $xmlCache = []; private static array $htmlCache = []; /** * @param bool $html Whether HTML support should be enabled. Disable it for XML documents */ public function __construct(bool $html = true) { $this->translator = new Translator(); if ($html) { $this->translator->registerExtension(new HtmlExtension($this->translator)); $this->cache = &self::$htmlCache; } else { $this->cache = &self::$xmlCache; } $this->translator ->registerParserShortcut(new EmptyStringParser()) ->registerParserShortcut(new ElementParser()) ->registerParserShortcut(new ClassParser()) ->registerParserShortcut(new HashParser()) ; } /** * Translates a CSS expression to its XPath equivalent. * * Optionally, a prefix can be added to the resulting XPath * expression with the $prefix parameter. */ public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string { $cacheKey = $prefix."\0".$cssExpr; if (isset($this->cache[$cacheKey])) { // Move the item last in cache (LRU) $value = $this->cache[$cacheKey]; unset($this->cache[$cacheKey]); return $this->cache[$cacheKey] = $value; } if (\count($this->cache) >= self::$maxCachedItems) { // Evict the oldest entry unset($this->cache[array_key_first($this->cache)]); } return $this->cache[$cacheKey] = $this->translator->cssToXPath($cssExpr, $prefix); } } ================================================ FILE: Exception/ExceptionInterface.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Exception; /** * Interface for exceptions. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon */ interface ExceptionInterface extends \Throwable { } ================================================ FILE: Exception/ExpressionErrorException.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Exception; /** * ParseException is thrown when a CSS selector syntax is not valid. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon */ class ExpressionErrorException extends ParseException { } ================================================ FILE: Exception/InternalErrorException.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Exception; /** * ParseException is thrown when a CSS selector syntax is not valid. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon */ class InternalErrorException extends ParseException { } ================================================ FILE: Exception/ParseException.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Exception; /** * ParseException is thrown when a CSS selector syntax is not valid. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Fabien Potencier */ class ParseException extends \Exception implements ExceptionInterface { } ================================================ FILE: Exception/SyntaxErrorException.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Exception; use Symfony\Component\CssSelector\Parser\Token; /** * ParseException is thrown when a CSS selector syntax is not valid. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon */ class SyntaxErrorException extends ParseException { public static function unexpectedToken(string $expectedValue, Token $foundToken): self { return new self(\sprintf('Expected %s, but %s found.', $expectedValue, $foundToken)); } public static function pseudoElementFound(string $pseudoElement, string $unexpectedLocation): self { return new self(\sprintf('Unexpected pseudo-element "::%s" found %s.', $pseudoElement, $unexpectedLocation)); } public static function unclosedString(int $position): self { return new self(\sprintf('Unclosed/invalid string at %s.', $position)); } public static function nestedNot(): self { return new self('Got nested ::not().'); } public static function notAtTheStartOfASelector(string $pseudoElement): self { return new self(\sprintf('Got immediate child pseudo-element ":%s" not at the start of a selector', $pseudoElement)); } public static function stringAsFunctionArgument(): self { return new self('String not allowed as function argument.'); } } ================================================ FILE: LICENSE ================================================ Copyright (c) 2004-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Node/AbstractNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Abstract base node class. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ abstract class AbstractNode implements NodeInterface { private string $nodeName; public function getNodeName(): string { return $this->nodeName ??= preg_replace('~.*\\\\([^\\\\]+)Node$~', '$1', static::class); } } ================================================ FILE: Node/AttributeNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a "[| ]" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class AttributeNode extends AbstractNode { public function __construct( private NodeInterface $selector, private ?string $namespace, private string $attribute, private string $operator, private ?string $value, ) { } public function getSelector(): NodeInterface { return $this->selector; } public function getNamespace(): ?string { return $this->namespace; } public function getAttribute(): string { return $this->attribute; } public function getOperator(): string { return $this->operator; } public function getValue(): ?string { return $this->value; } public function getSpecificity(): Specificity { return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); } public function __toString(): string { $attribute = $this->namespace ? $this->namespace.'|'.$this->attribute : $this->attribute; return 'exists' === $this->operator ? \sprintf('%s[%s[%s]]', $this->getNodeName(), $this->selector, $attribute) : \sprintf("%s[%s[%s %s '%s']]", $this->getNodeName(), $this->selector, $attribute, $this->operator, $this->value); } } ================================================ FILE: Node/ClassNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a "." node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class ClassNode extends AbstractNode { public function __construct( private NodeInterface $selector, private string $name, ) { } public function getSelector(): NodeInterface { return $this->selector; } public function getName(): string { return $this->name; } public function getSpecificity(): Specificity { return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); } public function __toString(): string { return \sprintf('%s[%s.%s]', $this->getNodeName(), $this->selector, $this->name); } } ================================================ FILE: Node/CombinedSelectorNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a combined node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class CombinedSelectorNode extends AbstractNode { public function __construct( private NodeInterface $selector, private string $combinator, private NodeInterface $subSelector, ) { } public function getSelector(): NodeInterface { return $this->selector; } public function getCombinator(): string { return $this->combinator; } public function getSubSelector(): NodeInterface { return $this->subSelector; } public function getSpecificity(): Specificity { return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); } public function __toString(): string { $combinator = ' ' === $this->combinator ? '' : $this->combinator; return \sprintf('%s[%s %s %s]', $this->getNodeName(), $this->selector, $combinator, $this->subSelector); } } ================================================ FILE: Node/ElementNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a "|" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class ElementNode extends AbstractNode { public function __construct( private ?string $namespace = null, private ?string $element = null, ) { } public function getNamespace(): ?string { return $this->namespace; } public function getElement(): ?string { return $this->element; } public function getSpecificity(): Specificity { return new Specificity(0, 0, $this->element ? 1 : 0); } public function __toString(): string { $element = $this->element ?: '*'; return \sprintf('%s[%s]', $this->getNodeName(), $this->namespace ? $this->namespace.'|'.$element : $element); } } ================================================ FILE: Node/FunctionNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; use Symfony\Component\CssSelector\Parser\Token; /** * Represents a ":()" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class FunctionNode extends AbstractNode { private string $name; /** * @param Token[] $arguments */ public function __construct( private NodeInterface $selector, string $name, private array $arguments = [], ) { $this->name = strtolower($name); } public function getSelector(): NodeInterface { return $this->selector; } public function getName(): string { return $this->name; } /** * @return Token[] */ public function getArguments(): array { return $this->arguments; } public function getSpecificity(): Specificity { return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); } public function __toString(): string { $arguments = implode(', ', array_map(static fn (Token $token) => "'".$token->getValue()."'", $this->arguments)); return \sprintf('%s[%s:%s(%s)]', $this->getNodeName(), $this->selector, $this->name, $arguments ? '['.$arguments.']' : ''); } } ================================================ FILE: Node/HashNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a "#" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class HashNode extends AbstractNode { public function __construct( private NodeInterface $selector, private string $id, ) { } public function getSelector(): NodeInterface { return $this->selector; } public function getId(): string { return $this->id; } public function getSpecificity(): Specificity { return $this->selector->getSpecificity()->plus(new Specificity(1, 0, 0)); } public function __toString(): string { return \sprintf('%s[%s#%s]', $this->getNodeName(), $this->selector, $this->id); } } ================================================ FILE: Node/MatchingNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a ":is()" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Hubert Lenoir * * @internal */ class MatchingNode extends AbstractNode { /** * @param array $arguments */ public function __construct( public readonly NodeInterface $selector, public readonly array $arguments = [], ) { } public function getSpecificity(): Specificity { $argumentsSpecificity = array_reduce( $this->arguments, static fn ($c, $n) => 1 === $n->getSpecificity()->compareTo($c) ? $n->getSpecificity() : $c, new Specificity(0, 0, 0), ); return $this->selector->getSpecificity()->plus($argumentsSpecificity); } public function __toString(): string { $selectorArguments = array_map( static fn ($n): string => ltrim((string) $n, '*'), $this->arguments, ); return \sprintf('%s[%s:is(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments)); } } ================================================ FILE: Node/NegationNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a ":not()" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class NegationNode extends AbstractNode { public function __construct( private NodeInterface $selector, private NodeInterface $subSelector, ) { } public function getSelector(): NodeInterface { return $this->selector; } public function getSubSelector(): NodeInterface { return $this->subSelector; } public function getSpecificity(): Specificity { return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); } public function __toString(): string { return \sprintf('%s[%s:not(%s)]', $this->getNodeName(), $this->selector, $this->subSelector); } } ================================================ FILE: Node/NodeInterface.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Interface for nodes. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ interface NodeInterface extends \Stringable { public function getNodeName(): string; public function getSpecificity(): Specificity; } ================================================ FILE: Node/PseudoNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a ":" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class PseudoNode extends AbstractNode { private string $identifier; public function __construct( private NodeInterface $selector, string $identifier, ) { $this->identifier = strtolower($identifier); } public function getSelector(): NodeInterface { return $this->selector; } public function getIdentifier(): string { return $this->identifier; } public function getSpecificity(): Specificity { return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); } public function __toString(): string { return \sprintf('%s[%s:%s]', $this->getNodeName(), $this->selector, $this->identifier); } } ================================================ FILE: Node/RelationNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a ":has()" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Franck Ranaivo-Harisoa * * @internal */ class RelationNode extends AbstractNode { public function __construct( private NodeInterface $selector, private string $combinator, private NodeInterface $subSelector, ) { } public function getSelector(): NodeInterface { return $this->selector; } public function getCombinator(): string { return $this->combinator; } public function getSubSelector(): NodeInterface { return $this->subSelector; } public function getSpecificity(): Specificity { return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); } public function __toString(): string { return \sprintf('%s[%s:has(%s)]', $this->getNodeName(), $this->selector, $this->subSelector); } } ================================================ FILE: Node/SelectorNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a "(::|:)" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class SelectorNode extends AbstractNode { private ?string $pseudoElement; public function __construct( private NodeInterface $tree, ?string $pseudoElement = null, ) { $this->pseudoElement = $pseudoElement ? strtolower($pseudoElement) : null; } public function getTree(): NodeInterface { return $this->tree; } public function getPseudoElement(): ?string { return $this->pseudoElement; } public function getSpecificity(): Specificity { return $this->tree->getSpecificity()->plus(new Specificity(0, 0, $this->pseudoElement ? 1 : 0)); } public function __toString(): string { return \sprintf('%s[%s%s]', $this->getNodeName(), $this->tree, $this->pseudoElement ? '::'.$this->pseudoElement : ''); } } ================================================ FILE: Node/Specificity.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a node specificity. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @see http://www.w3.org/TR/selectors/#specificity * * @author Jean-François Simon * * @internal */ class Specificity { public const A_FACTOR = 100; public const B_FACTOR = 10; public const C_FACTOR = 1; public function __construct( private int $a, private int $b, private int $c, ) { } public function plus(self $specificity): self { return new self($this->a + $specificity->a, $this->b + $specificity->b, $this->c + $specificity->c); } public function getValue(): int { return $this->a * self::A_FACTOR + $this->b * self::B_FACTOR + $this->c * self::C_FACTOR; } /** * Returns -1 if the object specificity is lower than the argument, * 0 if they are equal, and 1 if the argument is lower. */ public function compareTo(self $specificity): int { if ($this->a !== $specificity->a) { return $this->a > $specificity->a ? 1 : -1; } if ($this->b !== $specificity->b) { return $this->b > $specificity->b ? 1 : -1; } if ($this->c !== $specificity->c) { return $this->c > $specificity->c ? 1 : -1; } return 0; } } ================================================ FILE: Node/SpecificityAdjustmentNode.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Node; /** * Represents a ":where()" node. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Hubert Lenoir * * @internal */ class SpecificityAdjustmentNode extends AbstractNode { /** * @param array $arguments */ public function __construct( public readonly NodeInterface $selector, public readonly array $arguments = [], ) { } public function getSpecificity(): Specificity { return $this->selector->getSpecificity(); } public function __toString(): string { $selectorArguments = array_map( static fn ($n) => ltrim((string) $n, '*'), $this->arguments, ); return \sprintf('%s[%s:where(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments)); } } ================================================ FILE: Parser/Handler/CommentHandler.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Handler; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\TokenStream; /** * CSS selector comment handler. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class CommentHandler implements HandlerInterface { public function handle(Reader $reader, TokenStream $stream): bool { if ('/*' !== $reader->getSubstring(2)) { return false; } $offset = $reader->getOffset('*/'); if (false === $offset) { $reader->moveToEnd(); } else { $reader->moveForward($offset + 2); } return true; } } ================================================ FILE: Parser/Handler/HandlerInterface.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Handler; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\TokenStream; /** * CSS selector handler interface. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ interface HandlerInterface { public function handle(Reader $reader, TokenStream $stream): bool; } ================================================ FILE: Parser/Handler/HashHandler.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Handler; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; use Symfony\Component\CssSelector\Parser\TokenStream; /** * CSS selector comment handler. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class HashHandler implements HandlerInterface { public function __construct( private TokenizerPatterns $patterns, private TokenizerEscaping $escaping, ) { } public function handle(Reader $reader, TokenStream $stream): bool { $match = $reader->findPattern($this->patterns->getHashPattern()); if (!$match) { return false; } $value = $this->escaping->escapeUnicode($match[1]); $stream->push(new Token(Token::TYPE_HASH, $value, $reader->getPosition())); $reader->moveForward(\strlen($match[0])); return true; } } ================================================ FILE: Parser/Handler/IdentifierHandler.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Handler; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; use Symfony\Component\CssSelector\Parser\TokenStream; /** * CSS selector comment handler. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class IdentifierHandler implements HandlerInterface { public function __construct( private TokenizerPatterns $patterns, private TokenizerEscaping $escaping, ) { } public function handle(Reader $reader, TokenStream $stream): bool { $match = $reader->findPattern($this->patterns->getIdentifierPattern()); if (!$match) { return false; } $value = $this->escaping->escapeUnicode($match[0]); $stream->push(new Token(Token::TYPE_IDENTIFIER, $value, $reader->getPosition())); $reader->moveForward(\strlen($match[0])); return true; } } ================================================ FILE: Parser/Handler/NumberHandler.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Handler; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; use Symfony\Component\CssSelector\Parser\TokenStream; /** * CSS selector comment handler. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class NumberHandler implements HandlerInterface { public function __construct( private TokenizerPatterns $patterns, ) { } public function handle(Reader $reader, TokenStream $stream): bool { $match = $reader->findPattern($this->patterns->getNumberPattern()); if (!$match) { return false; } $stream->push(new Token(Token::TYPE_NUMBER, $match[0], $reader->getPosition())); $reader->moveForward(\strlen($match[0])); return true; } } ================================================ FILE: Parser/Handler/StringHandler.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Handler; use Symfony\Component\CssSelector\Exception\InternalErrorException; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; use Symfony\Component\CssSelector\Parser\TokenStream; /** * CSS selector comment handler. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class StringHandler implements HandlerInterface { public function __construct( private TokenizerPatterns $patterns, private TokenizerEscaping $escaping, ) { } public function handle(Reader $reader, TokenStream $stream): bool { $quote = $reader->getSubstring(1); if (!\in_array($quote, ["'", '"'], true)) { return false; } $reader->moveForward(1); $match = $reader->findPattern($this->patterns->getQuotedStringPattern($quote)); if (!$match) { throw new InternalErrorException(\sprintf('Should have found at least an empty match at %d.', $reader->getPosition())); } // check unclosed strings if (\strlen($match[0]) === $reader->getRemainingLength()) { throw SyntaxErrorException::unclosedString($reader->getPosition() - 1); } // check quotes pairs validity if ($quote !== $reader->getSubstring(1, \strlen($match[0]))) { throw SyntaxErrorException::unclosedString($reader->getPosition() - 1); } $string = $this->escaping->escapeUnicodeAndNewLine($match[0]); $stream->push(new Token(Token::TYPE_STRING, $string, $reader->getPosition())); $reader->moveForward(\strlen($match[0]) + 1); return true; } } ================================================ FILE: Parser/Handler/WhitespaceHandler.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Handler; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\TokenStream; /** * CSS selector whitespace handler. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class WhitespaceHandler implements HandlerInterface { public function handle(Reader $reader, TokenStream $stream): bool { $match = $reader->findPattern('~^[ \t\r\n\f]+~'); if (false === $match) { return false; } $stream->push(new Token(Token::TYPE_WHITESPACE, $match[0], $reader->getPosition())); $reader->moveForward(\strlen($match[0])); return true; } } ================================================ FILE: Parser/Parser.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser; use Symfony\Component\CssSelector\Exception\InternalErrorException; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; use Symfony\Component\CssSelector\Node; use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer; /** * CSS selector parser. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon * * @internal */ class Parser implements ParserInterface { private Tokenizer $tokenizer; public function __construct(?Tokenizer $tokenizer = null) { $this->tokenizer = $tokenizer ?? new Tokenizer(); } public function parse(string $source): array { $reader = new Reader($source); $stream = $this->tokenizer->tokenize($reader); return $this->parseSelectorList($stream); } /** * Parses the arguments for ":nth-child()" and friends. * * @param Token[] $tokens * * @throws SyntaxErrorException */ public static function parseSeries(array $tokens): array { foreach ($tokens as $token) { if ($token->isString()) { throw SyntaxErrorException::stringAsFunctionArgument(); } } $joined = trim(implode('', array_map(static fn (Token $token) => $token->getValue(), $tokens))); $int = static function ($string) { if (!is_numeric($string)) { throw SyntaxErrorException::stringAsFunctionArgument(); } return (int) $string; }; switch (true) { case 'odd' === $joined: return [2, 1]; case 'even' === $joined: return [2, 0]; case 'n' === $joined: return [1, 0]; case !str_contains($joined, 'n'): return [0, $int($joined)]; } $split = explode('n', $joined); $first = $split[0] ?? null; return [ $first ? ('-' === $first || '+' === $first ? $int($first.'1') : $int($first)) : 1, isset($split[1]) && $split[1] ? $int($split[1]) : 0, ]; } private function parseSelectorList(TokenStream $stream, bool $isArgument = false): array { $stream->skipWhitespace(); $selectors = []; while (true) { if ($isArgument && $stream->getPeek()->isDelimiter([')'])) { break; } $selectors[] = $this->parserSelectorNode($stream, $isArgument); if ($stream->getPeek()->isDelimiter([','])) { $stream->getNext(); $stream->skipWhitespace(); } else { break; } } return $selectors; } private function parserSelectorNode(TokenStream $stream, bool $isArgument = false): Node\SelectorNode { [$result, $pseudoElement] = $this->parseSimpleSelector($stream, false, $isArgument); while (true) { $stream->skipWhitespace(); $peek = $stream->getPeek(); if ( $peek->isFileEnd() || $peek->isDelimiter([',']) || ($isArgument && $peek->isDelimiter([')'])) ) { break; } if (null !== $pseudoElement) { throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector'); } if ($peek->isDelimiter(['+', '>', '~'])) { $combinator = $stream->getNext()->getValue(); $stream->skipWhitespace(); } else { $combinator = ' '; } [$nextSelector, $pseudoElement] = $this->parseSimpleSelector($stream, false, $isArgument); $result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector); } return new Node\SelectorNode($result, $pseudoElement); } /** * @throws SyntaxErrorException * @throws InternalErrorException */ private function parseRelativeSelector(TokenStream $stream): array { $stream->skipWhitespace(); $subSelector = ''; $next = $stream->getNext(); if ($next->isDelimiter(['+', '>', '~'])) { $combinator = $next->getValue(); $stream->skipWhitespace(); $next = $stream->getNext(); } else { $combinator = ' '; } while (true) { if ($next->isString() || $next->isIdentifier() || $next->isNumber() || $next->isDelimiter(['.', '*'])) { $subSelector .= $next->getValue(); } elseif ($next->isHash()) { $subSelector .= '#'.$next->getValue(); } elseif ($next->isDelimiter([')'])) { $result = $this->parse($subSelector); return [$combinator, $result[0]]; } else { throw SyntaxErrorException::unexpectedToken('an argument', $next); } $next = $stream->getNext(); } } /** * Parses next simple node (hash, class, pseudo, negation). * * @throws SyntaxErrorException * @throws InternalErrorException */ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false, bool $isArgument = false): array { $stream->skipWhitespace(); $selectorStart = \count($stream->getUsed()); $result = $this->parseElementNode($stream); $pseudoElement = null; while (true) { $peek = $stream->getPeek(); if ($peek->isWhitespace() || $peek->isFileEnd() || $peek->isDelimiter([',', '+', '>', '~']) || ($isArgument && $peek->isDelimiter([')'])) ) { break; } if (null !== $pseudoElement) { throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector'); } if ($peek->isHash()) { $result = new Node\HashNode($result, $stream->getNext()->getValue()); } elseif ($peek->isDelimiter(['.'])) { $stream->getNext(); $result = new Node\ClassNode($result, $stream->getNextIdentifier()); } elseif ($peek->isDelimiter(['['])) { $stream->getNext(); $result = $this->parseAttributeNode($result, $stream); } elseif ($peek->isDelimiter([':'])) { $stream->getNext(); if ($stream->getPeek()->isDelimiter([':'])) { $stream->getNext(); $pseudoElement = $stream->getNextIdentifier(); continue; } $identifier = $stream->getNextIdentifier(); if (\in_array(strtolower($identifier), ['first-line', 'first-letter', 'before', 'after'], true)) { // Special case: CSS 2.1 pseudo-elements can have a single ':'. // Any new pseudo-element must have two. $pseudoElement = $identifier; continue; } if (!$stream->getPeek()->isDelimiter(['('])) { $result = new Node\PseudoNode($result, $identifier); if ('Pseudo[Element[*]:scope]' === $result->__toString()) { $used = \count($stream->getUsed()); if (!(2 === $used || 3 === $used && $stream->getUsed()[0]->isWhiteSpace() || $used >= 3 && $stream->getUsed()[$used - 3]->isDelimiter([',']) || $used >= 4 && $stream->getUsed()[$used - 3]->isWhiteSpace() && $stream->getUsed()[$used - 4]->isDelimiter([',']) )) { throw SyntaxErrorException::notAtTheStartOfASelector('scope'); } } continue; } $stream->getNext(); $stream->skipWhitespace(); if ('not' === strtolower($identifier)) { if ($insideNegation) { throw SyntaxErrorException::nestedNot(); } [$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true, true); $next = $stream->getNext(); if (null !== $argumentPseudoElement) { throw SyntaxErrorException::pseudoElementFound($argumentPseudoElement, 'inside ::not()'); } if (!$next->isDelimiter([')'])) { throw SyntaxErrorException::unexpectedToken('")"', $next); } $result = new Node\NegationNode($result, $argument); } elseif ('is' === strtolower($identifier)) { $selectors = $this->parseSelectorList($stream, true); $next = $stream->getNext(); if (!$next->isDelimiter([')'])) { throw SyntaxErrorException::unexpectedToken('")"', $next); } $result = new Node\MatchingNode($result, $selectors); } elseif ('where' === strtolower($identifier)) { $selectors = $this->parseSelectorList($stream, true); $next = $stream->getNext(); if (!$next->isDelimiter([')'])) { throw SyntaxErrorException::unexpectedToken('")"', $next); } $result = new Node\SpecificityAdjustmentNode($result, $selectors); } elseif ('has' === strtolower($identifier)) { [$combinator, $arguments] = $this->parseRelativeSelector($stream); $result = new Node\RelationNode($result, $combinator, $arguments); } else { $arguments = []; $next = null; while (true) { $stream->skipWhitespace(); $next = $stream->getNext(); if ($next->isIdentifier() || $next->isString() || $next->isNumber() || $next->isDelimiter(['+', '-']) ) { $arguments[] = $next; } elseif ($next->isDelimiter([')'])) { break; } else { throw SyntaxErrorException::unexpectedToken('an argument', $next); } } if (!$arguments) { throw SyntaxErrorException::unexpectedToken('at least one argument', $next); } $result = new Node\FunctionNode($result, $identifier, $arguments); } } else { throw SyntaxErrorException::unexpectedToken('selector', $peek); } } if (\count($stream->getUsed()) === $selectorStart) { throw SyntaxErrorException::unexpectedToken('selector', $stream->getPeek()); } return [$result, $pseudoElement]; } private function parseElementNode(TokenStream $stream): Node\ElementNode { $peek = $stream->getPeek(); if ($peek->isIdentifier() || $peek->isDelimiter(['*'])) { if ($peek->isIdentifier()) { $namespace = $stream->getNext()->getValue(); } else { $stream->getNext(); $namespace = null; } if ($stream->getPeek()->isDelimiter(['|'])) { $stream->getNext(); $element = $stream->getNextIdentifierOrStar(); } else { $element = $namespace; $namespace = null; } } else { $element = $namespace = null; } return new Node\ElementNode($namespace, $element); } private function parseAttributeNode(Node\NodeInterface $selector, TokenStream $stream): Node\AttributeNode { $stream->skipWhitespace(); $attribute = $stream->getNextIdentifierOrStar(); if (null === $attribute && !$stream->getPeek()->isDelimiter(['|'])) { throw SyntaxErrorException::unexpectedToken('"|"', $stream->getPeek()); } if ($stream->getPeek()->isDelimiter(['|'])) { $stream->getNext(); if ($stream->getPeek()->isDelimiter(['='])) { $namespace = null; $stream->getNext(); $operator = '|='; } else { $namespace = $attribute; $attribute = $stream->getNextIdentifier(); $operator = null; } } else { $namespace = $operator = null; } if (null === $operator) { $stream->skipWhitespace(); $next = $stream->getNext(); if ($next->isDelimiter([']'])) { return new Node\AttributeNode($selector, $namespace, $attribute, 'exists', null); } elseif ($next->isDelimiter(['='])) { $operator = '='; } elseif ($next->isDelimiter(['^', '$', '*', '~', '|', '!']) && $stream->getPeek()->isDelimiter(['=']) ) { $operator = $next->getValue().'='; $stream->getNext(); } else { throw SyntaxErrorException::unexpectedToken('operator', $next); } } $stream->skipWhitespace(); $value = $stream->getNext(); if ($value->isNumber()) { // if the value is a number, it's casted into a string $value = new Token(Token::TYPE_STRING, (string) $value->getValue(), $value->getPosition()); } if (!($value->isIdentifier() || $value->isString())) { throw SyntaxErrorException::unexpectedToken('string or identifier', $value); } $stream->skipWhitespace(); $next = $stream->getNext(); if (!$next->isDelimiter([']'])) { throw SyntaxErrorException::unexpectedToken('"]"', $next); } return new Node\AttributeNode($selector, $namespace, $attribute, $operator, $value->getValue()); } } ================================================ FILE: Parser/ParserInterface.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser; use Symfony\Component\CssSelector\Node\SelectorNode; /** * CSS selector parser interface. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ interface ParserInterface { /** * Parses given selector source into an array of tokens. * * @return SelectorNode[] */ public function parse(string $source): array; } ================================================ FILE: Parser/Reader.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser; /** * CSS selector reader. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class Reader { private int $length; private int $position = 0; public function __construct( private string $source, ) { $this->length = \strlen($source); } public function isEOF(): bool { return $this->position >= $this->length; } public function getPosition(): int { return $this->position; } public function getRemainingLength(): int { return $this->length - $this->position; } public function getSubstring(int $length, int $offset = 0): string { return substr($this->source, $this->position + $offset, $length); } public function getOffset(string $string): int|false { $position = strpos($this->source, $string, $this->position); return false === $position ? false : $position - $this->position; } public function findPattern(string $pattern): array|false { $source = substr($this->source, $this->position); if (preg_match($pattern, $source, $matches)) { return $matches; } return false; } public function moveForward(int $length): void { $this->position += $length; } public function moveToEnd(): void { $this->position = $this->length; } } ================================================ FILE: Parser/Shortcut/ClassParser.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Shortcut; use Symfony\Component\CssSelector\Node\ClassNode; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\ParserInterface; /** * CSS selector class parser shortcut. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class ClassParser implements ParserInterface { public function parse(string $source): array { // Matches an optional namespace, optional element, and required class // $source = 'test|input.ab6bd_field'; // $matches = array (size=4) // 0 => string 'test|input.ab6bd_field' (length=22) // 1 => string 'test' (length=4) // 2 => string 'input' (length=5) // 3 => string 'ab6bd_field' (length=11) if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+\.([\w-]++)$/i', trim($source), $matches)) { return [ new SelectorNode(new ClassNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])), ]; } return []; } } ================================================ FILE: Parser/Shortcut/ElementParser.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Shortcut; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\ParserInterface; /** * CSS selector element parser shortcut. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class ElementParser implements ParserInterface { public function parse(string $source): array { // Matches an optional namespace, required element or `*` // $source = 'testns|testel'; // $matches = array (size=3) // 0 => string 'testns|testel' (length=13) // 1 => string 'testns' (length=6) // 2 => string 'testel' (length=6) if (preg_match('/^(?:([a-z]++)\|)?([\w-]++|\*)$/i', trim($source), $matches)) { return [new SelectorNode(new ElementNode($matches[1] ?: null, $matches[2]))]; } return []; } } ================================================ FILE: Parser/Shortcut/EmptyStringParser.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Shortcut; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\ParserInterface; /** * CSS selector class parser shortcut. * * This shortcut ensure compatibility with previous version. * - The parser fails to parse an empty string. * - In the previous version, an empty string matches each tags. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class EmptyStringParser implements ParserInterface { public function parse(string $source): array { // Matches an empty string if ('' == $source) { return [new SelectorNode(new ElementNode(null, '*'))]; } return []; } } ================================================ FILE: Parser/Shortcut/HashParser.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Shortcut; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\HashNode; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\ParserInterface; /** * CSS selector hash parser shortcut. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class HashParser implements ParserInterface { public function parse(string $source): array { // Matches an optional namespace, optional element, and required id // $source = 'test|input#ab6bd_field'; // $matches = array (size=4) // 0 => string 'test|input#ab6bd_field' (length=22) // 1 => string 'test' (length=4) // 2 => string 'input' (length=5) // 3 => string 'ab6bd_field' (length=11) if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+#([\w-]++)$/i', trim($source), $matches)) { return [ new SelectorNode(new HashNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])), ]; } return []; } } ================================================ FILE: Parser/Token.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser; /** * CSS selector token. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class Token { public const TYPE_FILE_END = 'eof'; public const TYPE_DELIMITER = 'delimiter'; public const TYPE_WHITESPACE = 'whitespace'; public const TYPE_IDENTIFIER = 'identifier'; public const TYPE_HASH = 'hash'; public const TYPE_NUMBER = 'number'; public const TYPE_STRING = 'string'; /** * @param self::TYPE_*|null $type */ public function __construct( private ?string $type, private ?string $value, private ?int $position, ) { } /** * @return self::TYPE_*|null */ public function getType(): ?string { return $this->type; } public function getValue(): ?string { return $this->value; } public function getPosition(): ?int { return $this->position; } public function isFileEnd(): bool { return self::TYPE_FILE_END === $this->type; } public function isDelimiter(array $values = []): bool { if (self::TYPE_DELIMITER !== $this->type) { return false; } if (!$values) { return true; } return \in_array($this->value, $values, true); } public function isWhitespace(): bool { return self::TYPE_WHITESPACE === $this->type; } public function isIdentifier(): bool { return self::TYPE_IDENTIFIER === $this->type; } public function isHash(): bool { return self::TYPE_HASH === $this->type; } public function isNumber(): bool { return self::TYPE_NUMBER === $this->type; } public function isString(): bool { return self::TYPE_STRING === $this->type; } public function __toString(): string { if ($this->value) { return \sprintf('<%s "%s" at %s>', $this->type, $this->value, $this->position); } return \sprintf('<%s at %s>', $this->type, $this->position); } } ================================================ FILE: Parser/TokenStream.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser; use Symfony\Component\CssSelector\Exception\InternalErrorException; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; /** * CSS selector token stream. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class TokenStream { /** * @var Token[] */ private array $tokens = []; /** * @var Token[] */ private array $used = []; private int $cursor = 0; private ?Token $peeked; private bool $peeking = false; /** * Pushes a token. * * @return $this */ public function push(Token $token): static { $this->tokens[] = $token; return $this; } /** * Freezes stream. * * @return $this */ public function freeze(): static { return $this; } /** * Returns next token. * * @throws InternalErrorException If there is no more token */ public function getNext(): Token { if ($this->peeking) { $this->peeking = false; $this->used[] = $this->peeked; return $this->peeked; } if (!isset($this->tokens[$this->cursor])) { throw new InternalErrorException('Unexpected token stream end.'); } return $this->tokens[$this->cursor++]; } /** * Returns peeked token. */ public function getPeek(): Token { if (!$this->peeking) { $this->peeked = $this->getNext(); $this->peeking = true; } return $this->peeked; } /** * Returns used tokens. * * @return Token[] */ public function getUsed(): array { return $this->used; } /** * Returns next identifier token. * * @throws SyntaxErrorException If next token is not an identifier */ public function getNextIdentifier(): string { $next = $this->getNext(); if (!$next->isIdentifier()) { throw SyntaxErrorException::unexpectedToken('identifier', $next); } return $next->getValue(); } /** * Returns next identifier or null if star delimiter token is found. * * @throws SyntaxErrorException If next token is not an identifier or a star delimiter */ public function getNextIdentifierOrStar(): ?string { $next = $this->getNext(); if ($next->isIdentifier()) { return $next->getValue(); } if ($next->isDelimiter(['*'])) { return null; } throw SyntaxErrorException::unexpectedToken('identifier or "*"', $next); } /** * Skips next whitespace if any. */ public function skipWhitespace(): void { $peek = $this->getPeek(); if ($peek->isWhitespace()) { $this->getNext(); } } } ================================================ FILE: Parser/Tokenizer/Tokenizer.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Tokenizer; use Symfony\Component\CssSelector\Parser\Handler; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\TokenStream; /** * CSS selector tokenizer. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class Tokenizer { /** * @var Handler\HandlerInterface[] */ private array $handlers; public function __construct() { $patterns = new TokenizerPatterns(); $escaping = new TokenizerEscaping($patterns); $this->handlers = [ new Handler\WhitespaceHandler(), new Handler\IdentifierHandler($patterns, $escaping), new Handler\HashHandler($patterns, $escaping), new Handler\StringHandler($patterns, $escaping), new Handler\NumberHandler($patterns), new Handler\CommentHandler(), ]; } /** * Tokenize selector source code. */ public function tokenize(Reader $reader): TokenStream { $stream = new TokenStream(); while (!$reader->isEOF()) { foreach ($this->handlers as $handler) { if ($handler->handle($reader, $stream)) { continue 2; } } $stream->push(new Token(Token::TYPE_DELIMITER, $reader->getSubstring(1), $reader->getPosition())); $reader->moveForward(1); } return $stream ->push(new Token(Token::TYPE_FILE_END, null, $reader->getPosition())) ->freeze(); } } ================================================ FILE: Parser/Tokenizer/TokenizerEscaping.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Tokenizer; /** * CSS selector tokenizer escaping applier. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class TokenizerEscaping { public function __construct( private TokenizerPatterns $patterns, ) { } public function escapeUnicode(string $value): string { $value = $this->replaceUnicodeSequences($value); return preg_replace($this->patterns->getSimpleEscapePattern(), '$1', $value); } public function escapeUnicodeAndNewLine(string $value): string { $value = preg_replace($this->patterns->getNewLineEscapePattern(), '', $value); return $this->escapeUnicode($value); } private function replaceUnicodeSequences(string $value): string { return preg_replace_callback($this->patterns->getUnicodeEscapePattern(), static function ($match) { $c = hexdec($match[1]); if (0x80 > $c %= 0x200000) { return \chr($c); } if (0x800 > $c) { return \chr(0xC0 | $c >> 6).\chr(0x80 | $c & 0x3F); } if (0x10000 > $c) { return \chr(0xE0 | $c >> 12).\chr(0x80 | $c >> 6 & 0x3F).\chr(0x80 | $c & 0x3F); } return ''; }, $value); } } ================================================ FILE: Parser/Tokenizer/TokenizerPatterns.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Parser\Tokenizer; /** * CSS selector tokenizer patterns builder. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class TokenizerPatterns { private string $unicodeEscapePattern; private string $simpleEscapePattern; private string $newLineEscapePattern; private string $escapePattern; private string $stringEscapePattern; private string $nonAsciiPattern; private string $nmCharPattern; private string $nmStartPattern; private string $identifierPattern; private string $hashPattern; private string $numberPattern; private string $quotedStringPattern; public function __construct() { $this->unicodeEscapePattern = '\\\\([0-9a-f]{1,6})(?:\r\n|[ \n\r\t\f])?'; $this->simpleEscapePattern = '\\\\(.)'; $this->newLineEscapePattern = '\\\\(?:\n|\r\n|\r|\f)'; $this->escapePattern = $this->unicodeEscapePattern.'|\\\\[^\n\r\f0-9a-f]'; $this->stringEscapePattern = $this->newLineEscapePattern.'|'.$this->escapePattern; $this->nonAsciiPattern = '[^\x00-\x7F]'; $this->nmCharPattern = '[_a-z0-9-]|'.$this->escapePattern.'|'.$this->nonAsciiPattern; $this->nmStartPattern = '[_a-z]|'.$this->escapePattern.'|'.$this->nonAsciiPattern; $this->identifierPattern = '-?(?:'.$this->nmStartPattern.')(?:'.$this->nmCharPattern.')*'; $this->hashPattern = '#((?:'.$this->nmCharPattern.')+)'; $this->numberPattern = '[+-]?(?:[0-9]*\.[0-9]+|[0-9]+)'; $this->quotedStringPattern = '([^\n\r\f\\\\%s]|'.$this->stringEscapePattern.')*'; } public function getNewLineEscapePattern(): string { return '~'.$this->newLineEscapePattern.'~'; } public function getSimpleEscapePattern(): string { return '~'.$this->simpleEscapePattern.'~'; } public function getUnicodeEscapePattern(): string { return '~'.$this->unicodeEscapePattern.'~i'; } public function getIdentifierPattern(): string { return '~^'.$this->identifierPattern.'~i'; } public function getHashPattern(): string { return '~^'.$this->hashPattern.'~i'; } public function getNumberPattern(): string { return '~^'.$this->numberPattern.'~'; } public function getQuotedStringPattern(string $quote): string { return '~^'.\sprintf($this->quotedStringPattern, $quote).'~i'; } } ================================================ FILE: README.md ================================================ CssSelector Component ===================== The CssSelector component converts CSS selectors to XPath expressions. Resources --------- * [Documentation](https://symfony.com/doc/current/components/css_selector.html) * [Contributing](https://symfony.com/doc/current/contributing/index.html) * [Report issues](https://github.com/symfony/symfony/issues) and [send Pull Requests](https://github.com/symfony/symfony/pulls) in the [main Symfony repository](https://github.com/symfony/symfony) Credits ------- This component is a port of the Python cssselect library [v0.7.1](https://github.com/SimonSapin/cssselect/releases/tag/v0.7.1), which is distributed under the BSD license. ================================================ FILE: Tests/CssSelectorConverterTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\CssSelectorConverter; use Symfony\Component\CssSelector\Exception\ParseException; class CssSelectorConverterTest extends TestCase { public function testCssToXPath() { $converter = new CssSelectorConverter(); $this->assertEquals('descendant-or-self::*', $converter->toXPath('')); $this->assertEquals('descendant-or-self::h1', $converter->toXPath('h1')); $this->assertEquals("descendant-or-self::h1[@id = 'foo']", $converter->toXPath('h1#foo')); $this->assertEquals("descendant-or-self::h1[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]", $converter->toXPath('h1.foo')); $this->assertEquals('descendant-or-self::foo:h1', $converter->toXPath('foo|h1')); $this->assertEquals('descendant-or-self::h1', $converter->toXPath('H1')); // Test the cache layer $converter = new CssSelectorConverter(); $this->assertEquals('descendant-or-self::h1', $converter->toXPath('H1')); } public function testCssToXPathXml() { $converter = new CssSelectorConverter(false); $this->assertEquals('descendant-or-self::H1', $converter->toXPath('H1')); $converter = new CssSelectorConverter(false); // Test the cache layer $this->assertEquals('descendant-or-self::H1', $converter->toXPath('H1')); } public function testParseExceptions() { $this->expectException(ParseException::class); $this->expectExceptionMessage('Expected identifier, but found.'); (new CssSelectorConverter())->toXPath('h1:'); } public function testLruCacheMovesRecentlyUsedToEnd() { CssSelectorConverter::$maxCachedItems = 5; $htmlCacheProperty = new \ReflectionProperty(CssSelectorConverter::class, 'htmlCache'); $htmlCacheProperty->setValue(null, []); $converter = new CssSelectorConverter(true); // Fill cache with 5 entries (h0-h4) for ($i = 0; $i < 5; ++$i) { $converter->toXPath("h$i"); } // Access h0 to move it to end (most recently used) $converter->toXPath('h0'); // Trigger eviction $converter->toXPath('h5'); $cache = $htmlCacheProperty->getValue(); // h0 was accessed recently (moved to end), so it survives eviction $this->assertArrayHasKey("descendant-or-self::\0h0", $cache); // h5 is the newest entry $this->assertArrayHasKey("descendant-or-self::\0h5", $cache); // h1 was the oldest untouched entry, should be evicted $this->assertArrayNotHasKey("descendant-or-self::\0h1", $cache); CssSelectorConverter::$maxCachedItems = 1024; } #[DataProvider('getCssToXPathWithoutPrefixTestData')] public function testCssToXPathWithoutPrefix($css, $xpath) { $converter = new CssSelectorConverter(); $this->assertEquals($xpath, $converter->toXPath($css, ''), '->parse() parses an input string and returns a node'); } public static function getCssToXPathWithoutPrefixTestData(): array { return [ ['h1', 'h1'], ['foo|h1', 'foo:h1'], ['h1, h2, h3', 'h1 | h2 | h3'], ['h1:nth-child(3n+1)', "*/*[(name() = 'h1') and (position() - 1 >= 0 and (position() - 1) mod 3 = 0)]"], ['h1 > p', 'h1/p'], ['h1#foo', "h1[@id = 'foo']"], ['h1.foo', "h1[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"], ['h1[class*="foo bar"]', "h1[@class and contains(@class, 'foo bar')]"], ['h1[foo|class*="foo bar"]', "h1[@foo:class and contains(@foo:class, 'foo bar')]"], ['h1[class]', 'h1[@class]'], ['h1 .foo', "h1/descendant-or-self::*/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"], ['h1 #foo', "h1/descendant-or-self::*/*[@id = 'foo']"], ['h1 [class*=foo]', "h1/descendant-or-self::*/*[@class and contains(@class, 'foo')]"], ['div>.foo', "div/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"], ['div > .foo', "div/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"], ]; } } ================================================ FILE: Tests/Node/AbstractNodeTestCase.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Node\NodeInterface; abstract class AbstractNodeTestCase extends TestCase { #[DataProvider('getToStringConversionTestData')] public function testToStringConversion(NodeInterface $node, $representation) { $this->assertEquals($representation, (string) $node); } #[DataProvider('getSpecificityValueTestData')] public function testSpecificityValue(NodeInterface $node, $value) { $this->assertEquals($value, $node->getSpecificity()->getValue()); } abstract public static function getToStringConversionTestData(); abstract public static function getSpecificityValueTestData(); } ================================================ FILE: Tests/Node/AttributeNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\AttributeNode; use Symfony\Component\CssSelector\Node\ElementNode; class AttributeNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new AttributeNode(new ElementNode(), null, 'attribute', 'exists', null), 'Attribute[Element[*][attribute]]'], [new AttributeNode(new ElementNode(), null, 'attribute', '$=', 'value'), "Attribute[Element[*][attribute $= 'value']]"], [new AttributeNode(new ElementNode(), 'namespace', 'attribute', '$=', 'value'), "Attribute[Element[*][namespace|attribute $= 'value']]"], ]; } public static function getSpecificityValueTestData() { return [ [new AttributeNode(new ElementNode(), null, 'attribute', 'exists', null), 10], [new AttributeNode(new ElementNode(null, 'element'), null, 'attribute', 'exists', null), 11], [new AttributeNode(new ElementNode(), null, 'attribute', '$=', 'value'), 10], [new AttributeNode(new ElementNode(), 'namespace', 'attribute', '$=', 'value'), 10], ]; } } ================================================ FILE: Tests/Node/ClassNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ClassNode; use Symfony\Component\CssSelector\Node\ElementNode; class ClassNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new ClassNode(new ElementNode(), 'class'), 'Class[Element[*].class]'], ]; } public static function getSpecificityValueTestData() { return [ [new ClassNode(new ElementNode(), 'class'), 10], [new ClassNode(new ElementNode(null, 'element'), 'class'), 11], ]; } } ================================================ FILE: Tests/Node/CombinedSelectorNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\CombinedSelectorNode; use Symfony\Component\CssSelector\Node\ElementNode; class CombinedSelectorNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new CombinedSelectorNode(new ElementNode(), '>', new ElementNode()), 'CombinedSelector[Element[*] > Element[*]]'], [new CombinedSelectorNode(new ElementNode(), ' ', new ElementNode()), 'CombinedSelector[Element[*] Element[*]]'], ]; } public static function getSpecificityValueTestData() { return [ [new CombinedSelectorNode(new ElementNode(), '>', new ElementNode()), 0], [new CombinedSelectorNode(new ElementNode(null, 'element'), '>', new ElementNode()), 1], [new CombinedSelectorNode(new ElementNode(null, 'element'), '>', new ElementNode(null, 'element')), 2], ]; } } ================================================ FILE: Tests/Node/ElementNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ElementNode; class ElementNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new ElementNode(), 'Element[*]'], [new ElementNode(null, 'element'), 'Element[element]'], [new ElementNode('namespace', 'element'), 'Element[namespace|element]'], ]; } public static function getSpecificityValueTestData() { return [ [new ElementNode(), 0], [new ElementNode(null, 'element'), 1], [new ElementNode('namespace', 'element'), 1], ]; } } ================================================ FILE: Tests/Node/FunctionNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\FunctionNode; use Symfony\Component\CssSelector\Parser\Token; class FunctionNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new FunctionNode(new ElementNode(), 'function'), 'Function[Element[*]:function()]'], [new FunctionNode(new ElementNode(), 'function', [ new Token(Token::TYPE_IDENTIFIER, 'value', 0), ]), "Function[Element[*]:function(['value'])]"], [new FunctionNode(new ElementNode(), 'function', [ new Token(Token::TYPE_STRING, 'value1', 0), new Token(Token::TYPE_NUMBER, 'value2', 0), ]), "Function[Element[*]:function(['value1', 'value2'])]"], ]; } public static function getSpecificityValueTestData() { return [ [new FunctionNode(new ElementNode(), 'function'), 10], [new FunctionNode(new ElementNode(), 'function', [ new Token(Token::TYPE_IDENTIFIER, 'value', 0), ]), 10], [new FunctionNode(new ElementNode(), 'function', [ new Token(Token::TYPE_STRING, 'value1', 0), new Token(Token::TYPE_NUMBER, 'value2', 0), ]), 10], ]; } } ================================================ FILE: Tests/Node/HashNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\HashNode; class HashNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new HashNode(new ElementNode(), 'id'), 'Hash[Element[*]#id]'], ]; } public static function getSpecificityValueTestData() { return [ [new HashNode(new ElementNode(), 'id'), 100], [new HashNode(new ElementNode(null, 'id'), 'class'), 101], ]; } } ================================================ FILE: Tests/Node/MatchingNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ClassNode; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\HashNode; use Symfony\Component\CssSelector\Node\MatchingNode; class MatchingNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new MatchingNode(new ElementNode(), [ new ClassNode(new ElementNode(), 'class'), new HashNode(new ElementNode(), 'id'), ]), 'Matching[Element[*]:is(Class[Element[*].class], Hash[Element[*]#id])]'], ]; } public static function getSpecificityValueTestData() { return [ [new MatchingNode(new ElementNode(), [ new ClassNode(new ElementNode(), 'class'), new HashNode(new ElementNode(), 'id'), ]), 100], [new MatchingNode(new ClassNode(new ElementNode(), 'class'), [ new ClassNode(new ElementNode(), 'class'), new HashNode(new ElementNode(), 'id'), ]), 110], [new MatchingNode(new HashNode(new ElementNode(), 'id'), [ new ClassNode(new ElementNode(), 'class'), new HashNode(new ElementNode(), 'id'), ]), 200], ]; } } ================================================ FILE: Tests/Node/NegationNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ClassNode; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\NegationNode; class NegationNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new NegationNode(new ElementNode(), new ClassNode(new ElementNode(), 'class')), 'Negation[Element[*]:not(Class[Element[*].class])]'], ]; } public static function getSpecificityValueTestData() { return [ [new NegationNode(new ElementNode(), new ClassNode(new ElementNode(), 'class')), 10], ]; } } ================================================ FILE: Tests/Node/PseudoNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\PseudoNode; class PseudoNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new PseudoNode(new ElementNode(), 'pseudo'), 'Pseudo[Element[*]:pseudo]'], ]; } public static function getSpecificityValueTestData() { return [ [new PseudoNode(new ElementNode(), 'pseudo'), 10], ]; } } ================================================ FILE: Tests/Node/SelectorNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\SelectorNode; class SelectorNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new SelectorNode(new ElementNode()), 'Selector[Element[*]]'], [new SelectorNode(new ElementNode(), 'pseudo'), 'Selector[Element[*]::pseudo]'], ]; } public static function getSpecificityValueTestData() { return [ [new SelectorNode(new ElementNode()), 0], [new SelectorNode(new ElementNode(), 'pseudo'), 1], ]; } } ================================================ FILE: Tests/Node/SpecificityAdjustmentNodeTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use Symfony\Component\CssSelector\Node\ClassNode; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\HashNode; use Symfony\Component\CssSelector\Node\SpecificityAdjustmentNode; class SpecificityAdjustmentNodeTest extends AbstractNodeTestCase { public static function getToStringConversionTestData() { return [ [new SpecificityAdjustmentNode(new ElementNode(), [ new ClassNode(new ElementNode(), 'class'), new HashNode(new ElementNode(), 'id'), ]), 'SpecificityAdjustment[Element[*]:where(Class[Element[*].class], Hash[Element[*]#id])]'], ]; } public static function getSpecificityValueTestData() { return [ [new SpecificityAdjustmentNode(new ElementNode(), [ new ClassNode(new ElementNode(), 'class'), new HashNode(new ElementNode(), 'id'), ]), 0], [new SpecificityAdjustmentNode(new ClassNode(new ElementNode(), 'class'), [ new ClassNode(new ElementNode(), 'class'), new HashNode(new ElementNode(), 'id'), ]), 10], ]; } } ================================================ FILE: Tests/Node/SpecificityTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Node; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Node\Specificity; class SpecificityTest extends TestCase { #[DataProvider('getValueTestData')] public function testValue(Specificity $specificity, $value) { $this->assertEquals($value, $specificity->getValue()); } #[DataProvider('getValueTestData')] public function testPlusValue(Specificity $specificity, $value) { $this->assertEquals($value + 123, $specificity->plus(new Specificity(1, 2, 3))->getValue()); } public static function getValueTestData() { return [ [new Specificity(0, 0, 0), 0], [new Specificity(0, 0, 2), 2], [new Specificity(0, 3, 0), 30], [new Specificity(4, 0, 0), 400], [new Specificity(4, 3, 2), 432], ]; } #[DataProvider('getCompareTestData')] public function testCompareTo(Specificity $a, Specificity $b, $result) { $this->assertEquals($result, $a->compareTo($b)); } public static function getCompareTestData() { return [ [new Specificity(0, 0, 0), new Specificity(0, 0, 0), 0], [new Specificity(0, 0, 1), new Specificity(0, 0, 1), 0], [new Specificity(0, 0, 2), new Specificity(0, 0, 1), 1], [new Specificity(0, 0, 2), new Specificity(0, 0, 3), -1], [new Specificity(0, 4, 0), new Specificity(0, 4, 0), 0], [new Specificity(0, 6, 0), new Specificity(0, 5, 11), 1], [new Specificity(0, 7, 0), new Specificity(0, 8, 0), -1], [new Specificity(9, 0, 0), new Specificity(9, 0, 0), 0], [new Specificity(11, 0, 0), new Specificity(10, 11, 0), 1], [new Specificity(12, 11, 0), new Specificity(13, 0, 0), -1], ]; } } ================================================ FILE: Tests/Parser/Handler/AbstractHandlerTestCase.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Handler; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\TokenStream; /** * @author Jean-François Simon */ abstract class AbstractHandlerTestCase extends TestCase { #[DataProvider('getHandleValueTestData')] public function testHandleValue($value, Token $expectedToken, $remainingContent) { $reader = new Reader($value); $stream = new TokenStream(); $this->assertTrue($this->generateHandler()->handle($reader, $stream)); $this->assertEquals($expectedToken, $stream->getNext()); $this->assertRemainingContent($reader, $remainingContent); } #[DataProvider('getDontHandleValueTestData')] public function testDontHandleValue($value) { $reader = new Reader($value); $stream = new TokenStream(); $this->assertFalse($this->generateHandler()->handle($reader, $stream)); $this->assertStreamEmpty($stream); $this->assertRemainingContent($reader, $value); } abstract public static function getHandleValueTestData(); abstract public static function getDontHandleValueTestData(); abstract protected function generateHandler(); protected function assertStreamEmpty(TokenStream $stream) { $property = new \ReflectionProperty($stream, 'tokens'); $this->assertEquals([], $property->getValue($stream)); } protected function assertRemainingContent(Reader $reader, $remainingContent) { if ('' === $remainingContent) { $this->assertEquals(0, $reader->getRemainingLength()); $this->assertTrue($reader->isEOF()); } else { $this->assertEquals(\strlen($remainingContent), $reader->getRemainingLength()); $this->assertEquals(0, $reader->getOffset($remainingContent)); } } } ================================================ FILE: Tests/Parser/Handler/CommentHandlerTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Handler; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\CssSelector\Parser\Handler\CommentHandler; use Symfony\Component\CssSelector\Parser\Reader; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\TokenStream; class CommentHandlerTest extends AbstractHandlerTestCase { #[DataProvider('getHandleValueTestData')] public function testHandleValue($value, Token $unusedArgument, $remainingContent) { $reader = new Reader($value); $stream = new TokenStream(); $this->assertTrue($this->generateHandler()->handle($reader, $stream)); // comments are ignored (not pushed as token in stream) $this->assertStreamEmpty($stream); $this->assertRemainingContent($reader, $remainingContent); } public static function getHandleValueTestData() { return [ // 2nd argument only exists for inherited method compatibility ['/* comment */', new Token(null, null, null), ''], ['/* comment */foo', new Token(null, null, null), 'foo'], ]; } public static function getDontHandleValueTestData() { return [ ['>'], ['+'], [' '], ]; } protected function generateHandler() { return new CommentHandler(); } } ================================================ FILE: Tests/Parser/Handler/HashHandlerTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Handler; use Symfony\Component\CssSelector\Parser\Handler\HashHandler; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; class HashHandlerTest extends AbstractHandlerTestCase { public static function getHandleValueTestData() { return [ ['#id', new Token(Token::TYPE_HASH, 'id', 0), ''], ['#123', new Token(Token::TYPE_HASH, '123', 0), ''], ['#id.class', new Token(Token::TYPE_HASH, 'id', 0), '.class'], ['#id element', new Token(Token::TYPE_HASH, 'id', 0), ' element'], ]; } public static function getDontHandleValueTestData() { return [ ['id'], ['123'], ['<'], ['<'], ['#'], ]; } protected function generateHandler() { $patterns = new TokenizerPatterns(); return new HashHandler($patterns, new TokenizerEscaping($patterns)); } } ================================================ FILE: Tests/Parser/Handler/IdentifierHandlerTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Handler; use Symfony\Component\CssSelector\Parser\Handler\IdentifierHandler; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; class IdentifierHandlerTest extends AbstractHandlerTestCase { public static function getHandleValueTestData() { return [ ['foo', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), ''], ['foo|bar', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '|bar'], ['foo.class', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '.class'], ['foo[attr]', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '[attr]'], ['foo bar', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), ' bar'], ]; } public static function getDontHandleValueTestData() { return [ ['>'], ['+'], [' '], ['*|foo'], ['/* comment */'], ]; } protected function generateHandler() { $patterns = new TokenizerPatterns(); return new IdentifierHandler($patterns, new TokenizerEscaping($patterns)); } } ================================================ FILE: Tests/Parser/Handler/NumberHandlerTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Handler; use Symfony\Component\CssSelector\Parser\Handler\NumberHandler; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; class NumberHandlerTest extends AbstractHandlerTestCase { public static function getHandleValueTestData() { return [ ['12', new Token(Token::TYPE_NUMBER, '12', 0), ''], ['12.34', new Token(Token::TYPE_NUMBER, '12.34', 0), ''], ['+12.34', new Token(Token::TYPE_NUMBER, '+12.34', 0), ''], ['-12.34', new Token(Token::TYPE_NUMBER, '-12.34', 0), ''], ['12 arg', new Token(Token::TYPE_NUMBER, '12', 0), ' arg'], ['12]', new Token(Token::TYPE_NUMBER, '12', 0), ']'], ]; } public static function getDontHandleValueTestData() { return [ ['hello'], ['>'], ['+'], [' '], ['/* comment */'], ]; } protected function generateHandler() { $patterns = new TokenizerPatterns(); return new NumberHandler($patterns); } } ================================================ FILE: Tests/Parser/Handler/StringHandlerTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Handler; use Symfony\Component\CssSelector\Parser\Handler\StringHandler; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; class StringHandlerTest extends AbstractHandlerTestCase { public static function getHandleValueTestData() { return [ ['"hello"', new Token(Token::TYPE_STRING, 'hello', 1), ''], ['"1"', new Token(Token::TYPE_STRING, '1', 1), ''], ['" "', new Token(Token::TYPE_STRING, ' ', 1), ''], ['""', new Token(Token::TYPE_STRING, '', 1), ''], ["'hello'", new Token(Token::TYPE_STRING, 'hello', 1), ''], ["'foo'bar", new Token(Token::TYPE_STRING, 'foo', 1), 'bar'], ]; } public static function getDontHandleValueTestData() { return [ ['hello'], ['>'], ['1'], [' '], ]; } protected function generateHandler() { $patterns = new TokenizerPatterns(); return new StringHandler($patterns, new TokenizerEscaping($patterns)); } } ================================================ FILE: Tests/Parser/Handler/WhitespaceHandlerTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Handler; use Symfony\Component\CssSelector\Parser\Handler\WhitespaceHandler; use Symfony\Component\CssSelector\Parser\Token; class WhitespaceHandlerTest extends AbstractHandlerTestCase { public static function getHandleValueTestData() { return [ [' ', new Token(Token::TYPE_WHITESPACE, ' ', 0), ''], ["\n", new Token(Token::TYPE_WHITESPACE, "\n", 0), ''], ["\t", new Token(Token::TYPE_WHITESPACE, "\t", 0), ''], [' foo', new Token(Token::TYPE_WHITESPACE, ' ', 0), 'foo'], [' .foo', new Token(Token::TYPE_WHITESPACE, ' ', 0), '.foo'], ]; } public static function getDontHandleValueTestData() { return [ ['>'], ['1'], ['a'], ]; } protected function generateHandler() { return new WhitespaceHandler(); } } ================================================ FILE: Tests/Parser/ParserTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; use Symfony\Component\CssSelector\Node\FunctionNode; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\Parser; use Symfony\Component\CssSelector\Parser\Token; class ParserTest extends TestCase { #[DataProvider('getParserTestData')] public function testParser($source, $representation) { $parser = new Parser(); $this->assertEquals($representation, array_map(static fn (SelectorNode $node) => (string) $node->getTree(), $parser->parse($source))); } #[DataProvider('getParserExceptionTestData')] public function testParserException($source, $message) { $parser = new Parser(); try { $parser->parse($source); $this->fail('Parser should throw a SyntaxErrorException.'); } catch (SyntaxErrorException $e) { $this->assertEquals($message, $e->getMessage()); } } #[DataProvider('getPseudoElementsTestData')] public function testPseudoElements($source, $element, $pseudo) { $parser = new Parser(); $selectors = $parser->parse($source); $this->assertCount(1, $selectors); /** @var SelectorNode $selector */ $selector = $selectors[0]; $this->assertEquals($element, (string) $selector->getTree()); $this->assertEquals($pseudo, (string) $selector->getPseudoElement()); } #[DataProvider('getSpecificityTestData')] public function testSpecificity($source, $value) { $parser = new Parser(); $selectors = $parser->parse($source); $this->assertCount(1, $selectors); /** @var SelectorNode $selector */ $selector = $selectors[0]; $this->assertEquals($value, $selector->getSpecificity()->getValue()); } #[DataProvider('getParseSeriesTestData')] public function testParseSeries($series, $a, $b) { $parser = new Parser(); $selectors = $parser->parse(\sprintf(':nth-child(%s)', $series)); $this->assertCount(1, $selectors); /** @var FunctionNode $function */ $function = $selectors[0]->getTree(); $this->assertEquals([$a, $b], Parser::parseSeries($function->getArguments())); } #[DataProvider('getParseSeriesExceptionTestData')] public function testParseSeriesException($series) { $parser = new Parser(); $selectors = $parser->parse(\sprintf(':nth-child(%s)', $series)); $this->assertCount(1, $selectors); /** @var FunctionNode $function */ $function = $selectors[0]->getTree(); $this->expectException(SyntaxErrorException::class); Parser::parseSeries($function->getArguments()); } public static function getParserTestData() { return [ ['*', ['Element[*]']], ['*|*', ['Element[*]']], ['*|foo', ['Element[foo]']], ['foo|*', ['Element[foo|*]']], ['foo|bar', ['Element[foo|bar]']], ['#foo#bar', ['Hash[Hash[Element[*]#foo]#bar]']], ['div>.foo', ['CombinedSelector[Element[div] > Class[Element[*].foo]]']], ['div> .foo', ['CombinedSelector[Element[div] > Class[Element[*].foo]]']], ['div >.foo', ['CombinedSelector[Element[div] > Class[Element[*].foo]]']], ['div > .foo', ['CombinedSelector[Element[div] > Class[Element[*].foo]]']], ["div \n> \t \t .foo", ['CombinedSelector[Element[div] > Class[Element[*].foo]]']], ['td.foo,.bar', ['Class[Element[td].foo]', 'Class[Element[*].bar]']], ['td.foo, .bar', ['Class[Element[td].foo]', 'Class[Element[*].bar]']], ["td.foo\t\r\n\f ,\t\r\n\f .bar", ['Class[Element[td].foo]', 'Class[Element[*].bar]']], ['td.foo,.bar', ['Class[Element[td].foo]', 'Class[Element[*].bar]']], ['td.foo, .bar', ['Class[Element[td].foo]', 'Class[Element[*].bar]']], ["td.foo\t\r\n\f ,\t\r\n\f .bar", ['Class[Element[td].foo]', 'Class[Element[*].bar]']], ['div, td.foo, div.bar span', ['Element[div]', 'Class[Element[td].foo]', 'CombinedSelector[Class[Element[div].bar] Element[span]]']], ['div > p', ['CombinedSelector[Element[div] > Element[p]]']], ['td:first', ['Pseudo[Element[td]:first]']], ['td :first', ['CombinedSelector[Element[td] Pseudo[Element[*]:first]]']], ['a[name]', ['Attribute[Element[a][name]]']], ["a[ name\t]", ['Attribute[Element[a][name]]']], ['a [name]', ['CombinedSelector[Element[a] Attribute[Element[*][name]]]']], ['[name="foo"]', ["Attribute[Element[*][name = 'foo']]"]], ["[name='foo[1]']", ["Attribute[Element[*][name = 'foo[1]']]"]], ["[name='foo[0][bar]']", ["Attribute[Element[*][name = 'foo[0][bar]']]"]], ['a[rel="include"]', ["Attribute[Element[a][rel = 'include']]"]], ['a[rel = include]', ["Attribute[Element[a][rel = 'include']]"]], ["a[hreflang |= 'en']", ["Attribute[Element[a][hreflang |= 'en']]"]], ['a[hreflang|=en]', ["Attribute[Element[a][hreflang |= 'en']]"]], ['div:nth-child(10)', ["Function[Element[div]:nth-child(['10'])]"]], [':nth-child(2n+2)', ["Function[Element[*]:nth-child(['2', 'n', '+2'])]"]], ['div:nth-of-type(10)', ["Function[Element[div]:nth-of-type(['10'])]"]], ['div div:nth-of-type(10) .aclass', ["CombinedSelector[CombinedSelector[Element[div] Function[Element[div]:nth-of-type(['10'])]] Class[Element[*].aclass]]"]], ['label:only', ['Pseudo[Element[label]:only]']], ['a:lang(fr)', ["Function[Element[a]:lang(['fr'])]"]], ['div:contains("foo")', ["Function[Element[div]:contains(['foo'])]"]], ['div#foobar', ['Hash[Element[div]#foobar]']], ['div:not(div.foo)', ['Negation[Element[div]:not(Class[Element[div].foo])]']], ['div:has(div.foo)', ['Relation[Element[div]:has(Selector[Class[Element[div].foo]])]']], ['td ~ th', ['CombinedSelector[Element[td] ~ Element[th]]']], ['.foo[data-bar][data-baz=0]', ["Attribute[Attribute[Class[Element[*].foo][data-bar]][data-baz = '0']]"]], ['div#foo\.bar', ['Hash[Element[div]#foo.bar]']], ['div.w-1\/3', ['Class[Element[div].w-1/3]']], ['#test\:colon', ['Hash[Element[*]#test:colon]']], [".a\xc1b", ["Class[Element[*].a\xc1b]"]], // unicode escape: \22 == " ['*[aval="\'\22\'"]', ['Attribute[Element[*][aval = \'\'"\'\']]']], ['*[aval="\'\22 2\'"]', ['Attribute[Element[*][aval = \'\'"2\'\']]']], // unicode escape: \20 == (space) ['*[aval="\'\20 \'"]', ['Attribute[Element[*][aval = \'\' \'\']]']], ["*[aval=\"'\\20\r\n '\"]", ['Attribute[Element[*][aval = \'\' \'\']]']], [':scope > foo', ['CombinedSelector[Pseudo[Element[*]:scope] > Element[foo]]']], [':scope > foo bar > div', ['CombinedSelector[CombinedSelector[CombinedSelector[Pseudo[Element[*]:scope] > Element[foo]] Element[bar]] > Element[div]]']], [':scope > #foo #bar', ['CombinedSelector[CombinedSelector[Pseudo[Element[*]:scope] > Hash[Element[*]#foo]] Hash[Element[*]#bar]]']], [':scope', ['Pseudo[Element[*]:scope]']], ['foo bar, :scope > div', ['CombinedSelector[Element[foo] Element[bar]]', 'CombinedSelector[Pseudo[Element[*]:scope] > Element[div]]']], ['foo bar,:scope > div', ['CombinedSelector[Element[foo] Element[bar]]', 'CombinedSelector[Pseudo[Element[*]:scope] > Element[div]]']], ['div:is(.foo, #bar)', ['Matching[Element[div]:is(Selector[Class[Element[*].foo]], Selector[Hash[Element[*]#bar]])]']], [':is(:hover, :visited)', ['Matching[Element[*]:is(Selector[Pseudo[Element[*]:hover]], Selector[Pseudo[Element[*]:visited]])]']], ['div:where(.foo, #bar)', ['SpecificityAdjustment[Element[div]:where(Selector[Class[Element[*].foo]], Selector[Hash[Element[*]#bar]])]']], [':where(:hover, :visited)', ['SpecificityAdjustment[Element[*]:where(Selector[Pseudo[Element[*]:hover]], Selector[Pseudo[Element[*]:visited]])]']], ]; } public static function getParserExceptionTestData() { return [ ['attributes(href)/html/body/a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '(', 10))->getMessage()], ['attributes(href)', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '(', 10))->getMessage()], ['html/body/a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '/', 4))->getMessage()], [' ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 1))->getMessage()], ['div, ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 5))->getMessage()], [' , div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, ',', 1))->getMessage()], ['p, , div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, ',', 3))->getMessage()], ['div > ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 6))->getMessage()], [' > div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '>', 2))->getMessage()], ['foo|#bar', SyntaxErrorException::unexpectedToken('identifier or "*"', new Token(Token::TYPE_HASH, 'bar', 4))->getMessage()], ['#.foo', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '#', 0))->getMessage()], ['.#foo', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_HASH, 'foo', 1))->getMessage()], [':#foo', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_HASH, 'foo', 1))->getMessage()], ['[*]', SyntaxErrorException::unexpectedToken('"|"', new Token(Token::TYPE_DELIMITER, ']', 2))->getMessage()], ['[foo|]', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_DELIMITER, ']', 5))->getMessage()], ['[#]', SyntaxErrorException::unexpectedToken('identifier or "*"', new Token(Token::TYPE_DELIMITER, '#', 1))->getMessage()], ['[foo=#]', SyntaxErrorException::unexpectedToken('string or identifier', new Token(Token::TYPE_DELIMITER, '#', 5))->getMessage()], [':nth-child()', SyntaxErrorException::unexpectedToken('at least one argument', new Token(Token::TYPE_DELIMITER, ')', 11))->getMessage()], ['[href]a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_IDENTIFIER, 'a', 6))->getMessage()], ['[rel:stylesheet]', SyntaxErrorException::unexpectedToken('operator', new Token(Token::TYPE_DELIMITER, ':', 4))->getMessage()], ['[rel=stylesheet', SyntaxErrorException::unexpectedToken('"]"', new Token(Token::TYPE_FILE_END, '', 15))->getMessage()], [':lang(fr', SyntaxErrorException::unexpectedToken('an argument', new Token(Token::TYPE_FILE_END, '', 8))->getMessage()], [':contains("foo', SyntaxErrorException::unclosedString(10)->getMessage()], ['foo!', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '!', 3))->getMessage()], [':scope > div :scope header', SyntaxErrorException::notAtTheStartOfASelector('scope')->getMessage()], [':not(:not(a))', SyntaxErrorException::nestedNot()->getMessage()], ]; } public static function getPseudoElementsTestData() { return [ ['foo', 'Element[foo]', ''], ['*', 'Element[*]', ''], [':empty', 'Pseudo[Element[*]:empty]', ''], [':BEfore', 'Element[*]', 'before'], [':aftER', 'Element[*]', 'after'], [':First-Line', 'Element[*]', 'first-line'], [':First-Letter', 'Element[*]', 'first-letter'], ['::befoRE', 'Element[*]', 'before'], ['::AFter', 'Element[*]', 'after'], ['::firsT-linE', 'Element[*]', 'first-line'], ['::firsT-letteR', 'Element[*]', 'first-letter'], ['::Selection', 'Element[*]', 'selection'], ['foo:after', 'Element[foo]', 'after'], ['foo::selection', 'Element[foo]', 'selection'], ['lorem#ipsum ~ a#b.c[href]:empty::selection', 'CombinedSelector[Hash[Element[lorem]#ipsum] ~ Pseudo[Attribute[Class[Hash[Element[a]#b].c][href]]:empty]]', 'selection'], ['video::-webkit-media-controls', 'Element[video]', '-webkit-media-controls'], ]; } public static function getSpecificityTestData() { return [ ['*', 0], [' foo', 1], [':empty ', 10], [':before', 1], ['*:before', 1], [':nth-child(2)', 10], ['.bar', 10], ['[baz]', 10], ['[baz="4"]', 10], ['[baz^="4"]', 10], ['#lipsum', 100], [':not(*)', 0], [':not(foo)', 1], [':not(.foo)', 10], [':not([foo])', 10], [':not(:empty)', 10], [':not(#foo)', 100], ['foo:empty', 11], ['foo:before', 2], ['foo::before', 2], ['foo:empty::before', 12], ['#lorem + foo#ipsum:first-child > bar:first-line', 213], [':is(*)', 0], [':is(foo)', 1], [':is(.foo)', 10], [':is(#foo)', 100], [':is(#foo, :empty, foo)', 100], ['#foo:is(#bar:empty)', 210], [':where(*)', 0], [':where(foo)', 0], [':where(.foo)', 0], [':where(#foo)', 0], [':where(#foo, :empty, foo)', 0], ['#foo:where(#bar:empty)', 100], ]; } public static function getParseSeriesTestData() { return [ ['1n+3', 1, 3], ['1n +3', 1, 3], ['1n + 3', 1, 3], ['1n+ 3', 1, 3], ['1n-3', 1, -3], ['1n -3', 1, -3], ['1n - 3', 1, -3], ['1n- 3', 1, -3], ['n-5', 1, -5], ['odd', 2, 1], ['even', 2, 0], ['3n', 3, 0], ['n', 1, 0], ['+n', 1, 0], ['-n', -1, 0], ['5', 0, 5], ]; } public static function getParseSeriesExceptionTestData() { return [ ['foo'], ['n+'], ]; } } ================================================ FILE: Tests/Parser/ReaderTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Parser\Reader; class ReaderTest extends TestCase { public function testIsEOF() { $reader = new Reader(''); $this->assertTrue($reader->isEOF()); $reader = new Reader('hello'); $this->assertFalse($reader->isEOF()); $this->assignPosition($reader, 2); $this->assertFalse($reader->isEOF()); $this->assignPosition($reader, 5); $this->assertTrue($reader->isEOF()); } public function testGetRemainingLength() { $reader = new Reader('hello'); $this->assertEquals(5, $reader->getRemainingLength()); $this->assignPosition($reader, 2); $this->assertEquals(3, $reader->getRemainingLength()); $this->assignPosition($reader, 5); $this->assertEquals(0, $reader->getRemainingLength()); } public function testGetSubstring() { $reader = new Reader('hello'); $this->assertEquals('he', $reader->getSubstring(2)); $this->assertEquals('el', $reader->getSubstring(2, 1)); $this->assignPosition($reader, 2); $this->assertEquals('ll', $reader->getSubstring(2)); $this->assertEquals('lo', $reader->getSubstring(2, 1)); } public function testGetOffset() { $reader = new Reader('hello'); $this->assertEquals(2, $reader->getOffset('ll')); $this->assertFalse($reader->getOffset('w')); $this->assignPosition($reader, 2); $this->assertEquals(0, $reader->getOffset('ll')); $this->assertFalse($reader->getOffset('he')); } public function testFindPattern() { $reader = new Reader('hello'); $this->assertFalse($reader->findPattern('/world/')); $this->assertEquals(['hello', 'h'], $reader->findPattern('/^([a-z]).*/')); $this->assignPosition($reader, 2); $this->assertFalse($reader->findPattern('/^h.*/')); $this->assertEquals(['llo'], $reader->findPattern('/^llo$/')); } public function testMoveForward() { $reader = new Reader('hello'); $this->assertEquals(0, $reader->getPosition()); $reader->moveForward(2); $this->assertEquals(2, $reader->getPosition()); } public function testToEnd() { $reader = new Reader('hello'); $reader->moveToEnd(); $this->assertTrue($reader->isEOF()); } private function assignPosition(Reader $reader, int $value) { $position = new \ReflectionProperty($reader, 'position'); $position->setValue($reader, $value); } } ================================================ FILE: Tests/Parser/Shortcut/ClassParserTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Shortcut; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\Shortcut\ClassParser; /** * @author Jean-François Simon */ class ClassParserTest extends TestCase { #[DataProvider('getParseTestData')] public function testParse($source, $representation) { $parser = new ClassParser(); $selectors = $parser->parse($source); $this->assertCount(1, $selectors); /** @var SelectorNode $selector */ $selector = $selectors[0]; $this->assertEquals($representation, (string) $selector->getTree()); } public static function getParseTestData() { return [ ['.testclass', 'Class[Element[*].testclass]'], ['testel.testclass', 'Class[Element[testel].testclass]'], ['testns|.testclass', 'Class[Element[testns|*].testclass]'], ['testns|*.testclass', 'Class[Element[testns|*].testclass]'], ['testns|testel.testclass', 'Class[Element[testns|testel].testclass]'], ]; } } ================================================ FILE: Tests/Parser/Shortcut/ElementParserTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Shortcut; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\Shortcut\ElementParser; /** * @author Jean-François Simon */ class ElementParserTest extends TestCase { #[DataProvider('getParseTestData')] public function testParse($source, $representation) { $parser = new ElementParser(); $selectors = $parser->parse($source); $this->assertCount(1, $selectors); /** @var SelectorNode $selector */ $selector = $selectors[0]; $this->assertEquals($representation, (string) $selector->getTree()); } public static function getParseTestData() { return [ ['*', 'Element[*]'], ['testel', 'Element[testel]'], ['testns|*', 'Element[testns|*]'], ['testns|testel', 'Element[testns|testel]'], ]; } } ================================================ FILE: Tests/Parser/Shortcut/EmptyStringParserTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Shortcut; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser; /** * @author Jean-François Simon */ class EmptyStringParserTest extends TestCase { public function testParse() { $parser = new EmptyStringParser(); $selectors = $parser->parse(''); $this->assertCount(1, $selectors); /** @var SelectorNode $selector */ $selector = $selectors[0]; $this->assertEquals('Element[*]', (string) $selector->getTree()); $selectors = $parser->parse('this will produce an empty array'); $this->assertCount(0, $selectors); } } ================================================ FILE: Tests/Parser/Shortcut/HashParserTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser\Shortcut; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\Shortcut\HashParser; /** * @author Jean-François Simon */ class HashParserTest extends TestCase { #[DataProvider('getParseTestData')] public function testParse($source, $representation) { $parser = new HashParser(); $selectors = $parser->parse($source); $this->assertCount(1, $selectors); /** @var SelectorNode $selector */ $selector = $selectors[0]; $this->assertEquals($representation, (string) $selector->getTree()); } public static function getParseTestData() { return [ ['#testid', 'Hash[Element[*]#testid]'], ['testel#testid', 'Hash[Element[testel]#testid]'], ['testns|#testid', 'Hash[Element[testns|*]#testid]'], ['testns|*#testid', 'Hash[Element[testns|*]#testid]'], ['testns|testel#testid', 'Hash[Element[testns|testel]#testid]'], ]; } } ================================================ FILE: Tests/Parser/TokenStreamTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\Parser; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; use Symfony\Component\CssSelector\Parser\Token; use Symfony\Component\CssSelector\Parser\TokenStream; class TokenStreamTest extends TestCase { public function testGetNext() { $stream = new TokenStream(); $stream->push($t1 = new Token(Token::TYPE_IDENTIFIER, 'h1', 0)); $stream->push($t2 = new Token(Token::TYPE_DELIMITER, '.', 2)); $stream->push($t3 = new Token(Token::TYPE_IDENTIFIER, 'title', 3)); $this->assertSame($t1, $stream->getNext()); $this->assertSame($t2, $stream->getNext()); $this->assertSame($t3, $stream->getNext()); } public function testGetPeek() { $stream = new TokenStream(); $stream->push($t1 = new Token(Token::TYPE_IDENTIFIER, 'h1', 0)); $stream->push($t2 = new Token(Token::TYPE_DELIMITER, '.', 2)); $stream->push($t3 = new Token(Token::TYPE_IDENTIFIER, 'title', 3)); $this->assertSame($t1, $stream->getPeek()); $this->assertSame($t1, $stream->getNext()); $this->assertSame($t2, $stream->getPeek()); $this->assertSame($t2, $stream->getPeek()); $this->assertSame($t2, $stream->getNext()); } public function testGetNextIdentifier() { $stream = new TokenStream(); $stream->push(new Token(Token::TYPE_IDENTIFIER, 'h1', 0)); $this->assertEquals('h1', $stream->getNextIdentifier()); } public function testFailToGetNextIdentifier() { $stream = new TokenStream(); $stream->push(new Token(Token::TYPE_DELIMITER, '.', 2)); $this->expectException(SyntaxErrorException::class); $stream->getNextIdentifier(); } public function testGetNextIdentifierOrStar() { $stream = new TokenStream(); $stream->push(new Token(Token::TYPE_IDENTIFIER, 'h1', 0)); $this->assertEquals('h1', $stream->getNextIdentifierOrStar()); $stream->push(new Token(Token::TYPE_DELIMITER, '*', 0)); $this->assertNull($stream->getNextIdentifierOrStar()); } public function testFailToGetNextIdentifierOrStar() { $stream = new TokenStream(); $stream->push(new Token(Token::TYPE_DELIMITER, '.', 2)); $this->expectException(SyntaxErrorException::class); $stream->getNextIdentifierOrStar(); } public function testSkipWhitespace() { $stream = new TokenStream(); $stream->push($t1 = new Token(Token::TYPE_IDENTIFIER, 'h1', 0)); $stream->push($t2 = new Token(Token::TYPE_WHITESPACE, ' ', 2)); $stream->push($t3 = new Token(Token::TYPE_IDENTIFIER, 'h1', 3)); $stream->skipWhitespace(); $this->assertSame($t1, $stream->getNext()); $stream->skipWhitespace(); $this->assertSame($t3, $stream->getNext()); } } ================================================ FILE: Tests/XPath/Fixtures/ids.html ================================================
link
  1. content

hi there guy

================================================ FILE: Tests/XPath/Fixtures/lang.xml ================================================ a b c d e f ================================================ FILE: Tests/XPath/Fixtures/shakespear.html ================================================

As You Like It

by William Shakespeare

ACT I, SCENE III. A room in the palace.

Enter CELIA and ROSALIND
CELIA
Why, cousin! why, Rosalind! Cupid have mercy! not a word?
ROSALIND
Not one to throw at a dog.
CELIA
No, thy words are too precious to be cast away upon
curs; throw some of them at me; come, lame me with reasons.
ROSALIND
CELIA
But is all this for your father?
Then there were two cousins laid up; when the one
should be lamed with reasons and the other mad
without any.
ROSALIND
No, some of it is for my child's father. O, how
full of briers is this working-day world!
CELIA
They are but burs, cousin, thrown upon thee in
holiday foolery: if we walk not in the trodden
paths our very petticoats will catch them.
ROSALIND
I could shake them off my coat: these burs are in my heart.
CELIA
Hem them away.
ROSALIND
I would try, if I could cry 'hem' and have him.
CELIA
Come, come, wrestle with thy affections.
ROSALIND
O, they take the part of a better wrestler than myself!
CELIA
O, a good wish upon you! you will try in time, in
despite of a fall. But, turning these jests out of
service, let us talk in good earnest: is it
possible, on such a sudden, you should fall into so
strong a liking with old Sir Rowland's youngest son?
ROSALIND
The duke my father loved his father dearly.
CELIA
Doth it therefore ensue that you should love his son
dearly? By this kind of chase, I should hate him,
for my father hated his father dearly; yet I hate
not Orlando.
ROSALIND
No, faith, hate him not, for my sake.
CELIA
Why should I not? doth he not deserve well?
ROSALIND
Let me love him for that, and do you love him
because I do. Look, here comes the duke.
CELIA
With his eyes full of anger.
Enter DUKE FREDERICK, with Lords
DUKE FREDERICK
Mistress, dispatch you with your safest haste
And get you from our court.
ROSALIND
Me, uncle?
DUKE FREDERICK
You, cousin
Within these ten days if that thou be'st found
So near our public court as twenty miles,
Thou diest for it.
ROSALIND
I do beseech your grace,
Let me the knowledge of my fault bear with me:
If with myself I hold intelligence
Or have acquaintance with mine own desires,
If that I do not dream or be not frantic,--
As I do trust I am not--then, dear uncle,
Never so much as in a thought unborn
Did I offend your highness.
DUKE FREDERICK
Thus do all traitors:
If their purgation did consist in words,
They are as innocent as grace itself:
Let it suffice thee that I trust thee not.
ROSALIND
Yet your mistrust cannot make me a traitor:
Tell me whereon the likelihood depends.
DUKE FREDERICK
Thou art thy father's daughter; there's enough.
ROSALIND
So was I when your highness took his dukedom;
So was I when your highness banish'd him:
Treason is not inherited, my lord;
Or, if we did derive it from our friends,
What's that to me? my father was no traitor:
Then, good my liege, mistake me not so much
To think my poverty is treacherous.
CELIA
Dear sovereign, hear me speak.
DUKE FREDERICK
Ay, Celia; we stay'd her for your sake,
Else had she with her father ranged along.
CELIA
I did not then entreat to have her stay;
It was your pleasure and your own remorse:
I was too young that time to value her;
But now I know her: if she be a traitor,
Why so am I; we still have slept together,
Rose at an instant, learn'd, play'd, eat together,
And wheresoever we went, like Juno's swans,
Still we went coupled and inseparable.
DUKE FREDERICK
She is too subtle for thee; and her smoothness,
Her very silence and her patience
Speak to the people, and they pity her.
Thou art a fool: she robs thee of thy name;
And thou wilt show more bright and seem more virtuous
When she is gone. Then open not thy lips:
Firm and irrevocable is my doom
Which I have pass'd upon her; she is banish'd.
CELIA
Pronounce that sentence then on me, my liege:
I cannot live out of her company.
DUKE FREDERICK
You are a fool. You, niece, provide yourself:
If you outstay the time, upon mine honour,
And in the greatness of my word, you die.
Exeunt DUKE FREDERICK and Lords
CELIA
O my poor Rosalind, whither wilt thou go?
Wilt thou change fathers? I will give thee mine.
I charge thee, be not thou more grieved than I am.
ROSALIND
I have more cause.
CELIA
Thou hast not, cousin;
Prithee be cheerful: know'st thou not, the duke
Hath banish'd me, his daughter?
ROSALIND
That he hath not.
CELIA
No, hath not? Rosalind lacks then the love
Which teacheth thee that thou and I am one:
Shall we be sunder'd? shall we part, sweet girl?
No: let my father seek another heir.
Therefore devise with me how we may fly,
Whither to go and what to bear with us;
And do not seek to take your change upon you,
To bear your griefs yourself and leave me out;
For, by this heaven, now at our sorrows pale,
Say what thou canst, I'll go along with thee.
ROSALIND
Why, whither shall we go?
CELIA
To seek my uncle in the forest of Arden.
ROSALIND
Alas, what danger will it be to us,
Maids as we are, to travel forth so far!
Beauty provoketh thieves sooner than gold.
CELIA
I'll put myself in poor and mean attire
And with a kind of umber smirch my face;
The like do you: so shall we pass along
And never stir assailants.
ROSALIND
Were it not better,
Because that I am more than common tall,
That I did suit me all points like a man?
A gallant curtle-axe upon my thigh,
A boar-spear in my hand; and--in my heart
Lie there what hidden woman's fear there will--
We'll have a swashing and a martial outside,
As many other mannish cowards have
That do outface it with their semblances.
CELIA
What shall I call thee when thou art a man?
ROSALIND
I'll have no worse a name than Jove's own page;
And therefore look you call me Ganymede.
But what will you be call'd?
CELIA
Something that hath a reference to my state
No longer Celia, but Aliena.
ROSALIND
But, cousin, what if we assay'd to steal
The clownish fool out of your father's court?
Would he not be a comfort to our travel?
CELIA
He'll go along o'er the wide world with me;
Leave me alone to woo him. Let's away,
And get our jewels and our wealth together,
Devise the fittest time and safest way
To hide us from pursuit that will be made
After my flight. Now go we in content
To liberty and not to banishment.
Exeunt
================================================ FILE: Tests/XPath/TranslatorTest.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\Tests\XPath; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\Exception\ExpressionErrorException; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; use Symfony\Component\CssSelector\Node\ElementNode; use Symfony\Component\CssSelector\Node\FunctionNode; use Symfony\Component\CssSelector\Parser\Parser; use Symfony\Component\CssSelector\XPath\Extension\HtmlExtension; use Symfony\Component\CssSelector\XPath\Translator; use Symfony\Component\CssSelector\XPath\XPathExpr; class TranslatorTest extends TestCase { #[DataProvider('getXpathLiteralTestData')] public function testXpathLiteral($value, $literal) { $this->assertEquals($literal, Translator::getXpathLiteral($value)); } #[DataProvider('getCssToXPathTestData')] public function testCssToXPath($css, $xpath) { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $this->assertEquals($xpath, $translator->cssToXPath($css, '')); } #[DataProvider('getUnsupportedHasSelectorTestData')] public function testHasUnsupportedSelector(string $css) { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $this->expectException(SyntaxErrorException::class); $translator->cssToXPath($css, ''); } public static function getUnsupportedHasSelectorTestData(): iterable { yield 'attribute selector' => ['div:has([data-x])']; yield 'descendant combinator' => ['div:has(.foo .bar)']; yield 'selector list' => ['div:has(.foo, .bar)']; yield 'nested pseudo-class' => ['div:has(:not(.foo))']; yield 'chained combinator' => ['div:has(> .foo > .bar)']; } public function testCssToXPathPseudoElement() { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $this->expectException(ExpressionErrorException::class); $translator->cssToXPath('e::first-line'); } public function testGetExtensionNotExistsExtension() { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $this->expectException(ExpressionErrorException::class); $translator->getExtension('fake'); } public function testAddCombinationNotExistsExtension() { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $parser = new Parser(); $xpath = $parser->parse('*')[0]; $combinedXpath = $parser->parse('*')[0]; $this->expectException(ExpressionErrorException::class); $translator->addCombination('fake', $xpath, $combinedXpath); } public function testAddFunctionNotExistsFunction() { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $xpath = new XPathExpr(); $function = new FunctionNode(new ElementNode(), 'fake'); $this->expectException(ExpressionErrorException::class); $translator->addFunction($xpath, $function); } public function testAddPseudoClassNotExistsClass() { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $xpath = new XPathExpr(); $this->expectException(ExpressionErrorException::class); $translator->addPseudoClass($xpath, 'fake'); } public function testAddAttributeMatchingClassNotExistsClass() { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $xpath = new XPathExpr(); $this->expectException(ExpressionErrorException::class); $translator->addAttributeMatching($xpath, '', '', ''); } #[DataProvider('getXmlLangTestData')] public function testXmlLang($css, array $elementsId) { $translator = new Translator(); $document = new \SimpleXMLElement(file_get_contents(__DIR__.'/Fixtures/lang.xml')); $elements = $document->xpath($translator->cssToXPath($css)); $this->assertCount(\count($elementsId), $elements); foreach ($elements as $element) { $this->assertContains((string) $element->attributes()->id, $elementsId); } } #[DataProvider('getHtmlIdsTestData')] public function testHtmlIds($css, array $elementsId) { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $document = new \DOMDocument(); $document->strictErrorChecking = false; $internalErrors = libxml_use_internal_errors(true); $document->loadHTMLFile(__DIR__.'/Fixtures/ids.html'); $document = simplexml_import_dom($document); $elements = $document->xpath($translator->cssToXPath($css)); $this->assertCount(\count($elementsId), $elements); foreach ($elements as $element) { if (null !== $element->attributes()->id) { $this->assertContains((string) $element->attributes()->id, $elementsId); } } libxml_clear_errors(); libxml_use_internal_errors($internalErrors); } #[DataProvider('getHtmlShakespearTestData')] public function testHtmlShakespear($css, $count) { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $document = new \DOMDocument(); $document->strictErrorChecking = false; $document->loadHTMLFile(__DIR__.'/Fixtures/shakespear.html'); $document = simplexml_import_dom($document); $bodies = $document->xpath('//body'); $elements = $bodies[0]->xpath($translator->cssToXPath($css)); $this->assertCount($count, $elements); } public function testOnlyOfTypeFindsSingleChildrenOfGivenType() { $translator = new Translator(); $translator->registerExtension(new HtmlExtension($translator)); $document = new \DOMDocument(); $document->loadHTML(<<<'HTML'

A

B C

HTML ); $xpath = new \DOMXPath($document); $nodeList = $xpath->query($translator->cssToXPath('span:only-of-type')); $this->assertSame(1, $nodeList->length); $this->assertSame('A', $nodeList->item(0)->textContent); } public static function getXpathLiteralTestData() { return [ ['foo', "'foo'"], ["foo's bar", '"foo\'s bar"'], ["foo's \"middle\" bar", 'concat(\'foo\', "\'", \'s "middle" bar\')'], ["foo's 'middle' \"bar\"", 'concat(\'foo\', "\'", \'s \', "\'", \'middle\', "\'", \' "bar"\')'], ]; } public static function getCssToXPathTestData() { return [ ['*', '*'], ['e', 'e'], ['*|e', 'e'], ['e|f', 'e:f'], ['e[foo]', 'e[@foo]'], ['e[foo|bar]', 'e[@foo:bar]'], ['e[foo="bar"]', "e[@foo = 'bar']"], ['e[foo~="bar"]', "e[@foo and contains(concat(' ', normalize-space(@foo), ' '), ' bar ')]"], ['e[foo^="bar"]', "e[@foo and starts-with(@foo, 'bar')]"], ['e[foo$="bar"]', "e[@foo and substring(@foo, string-length(@foo)-2) = 'bar']"], ['e[foo*="bar"]', "e[@foo and contains(@foo, 'bar')]"], ['e[foo!="bar"]', "e[not(@foo) or @foo != 'bar']"], ['e[foo!="bar"][foo!="baz"]', "e[(not(@foo) or @foo != 'bar') and (not(@foo) or @foo != 'baz')]"], ['e[hreflang|="en"]', "e[@hreflang and (@hreflang = 'en' or starts-with(@hreflang, 'en-'))]"], ['e:nth-child(1)', "*/*[(name() = 'e') and (position() = 1)]"], ['e:nth-last-child(1)', "*/*[(name() = 'e') and (position() = last() - 0)]"], ['e:nth-last-child(2n+2)', "*/*[(name() = 'e') and (last() - position() - 1 >= 0 and (last() - position() - 1) mod 2 = 0)]"], ['e:nth-of-type(1)', '*/e[position() = 1]'], ['e:nth-last-of-type(1)', '*/e[position() = last() - 0]'], ['div e:nth-last-of-type(1) .aclass', "div/descendant-or-self::*/e[position() = last() - 0]/descendant-or-self::*/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' aclass ')]"], ['e:first-child', "*/*[(name() = 'e') and (position() = 1)]"], ['e:last-child', "*/*[(name() = 'e') and (position() = last())]"], ['e:first-of-type', '*/e[position() = 1]'], ['e:last-of-type', '*/e[position() = last()]'], ['e:only-child', "*/*[(name() = 'e') and (last() = 1)]"], ['e:only-of-type', 'e[count(preceding-sibling::e)=0 and count(following-sibling::e)=0]'], ['e:empty', 'e[not(*) and not(string-length())]'], ['e:EmPTY', 'e[not(*) and not(string-length())]'], ['e:root', 'e[not(parent::*)]'], ['e:hover', 'e[0]'], ['e:contains("foo")', "e[contains(string(.), 'foo')]"], ['e:ConTains(foo)', "e[contains(string(.), 'foo')]"], ['e.warning', "e[@class and contains(concat(' ', normalize-space(@class), ' '), ' warning ')]"], ['e#myid', "e[@id = 'myid']"], ['e:not(:nth-child(odd))', 'e[not(position() - 1 >= 0 and (position() - 1) mod 2 = 0)]'], ['e:nOT(*)', 'e[0]'], ['e f', 'e/descendant-or-self::*/f'], ['e > f', 'e/f'], ['e + f', "e/following-sibling::*[(name() = 'f') and (position() = 1)]"], ['e ~ f', 'e/following-sibling::f'], ['div#container p', "div[@id = 'container']/descendant-or-self::*/p"], [':scope > div[dataimg=""]', "*[1]/div[@dataimg = '']"], [':scope', '*[1]'], ['e:is(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"], ['e:where(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"], ['div:has(> .foo)', "div[./*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"], ['div:has(~ .foo)', "div[following-sibling::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"], ['div:has(+ .foo)', "div[following-sibling::*[(@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')) and (position() = 1)]]"], ['div:has(.foo)', "div[descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"], ['div:has(#bar)', "div[descendant-or-self::*[@id = 'bar']]"], ]; } public static function getXmlLangTestData() { return [ [':lang("EN")', ['first', 'second', 'third', 'fourth']], [':lang("en-us")', ['second', 'fourth']], [':lang(en-nz)', ['third']], [':lang(fr)', ['fifth']], [':lang(ru)', ['sixth']], [":lang('ZH')", ['eighth']], [':lang(de) :lang(zh)', ['eighth']], [':lang(en), :lang(zh)', ['first', 'second', 'third', 'fourth', 'eighth']], [':lang(es)', []], ]; } public static function getHtmlIdsTestData() { return [ ['div', ['outer-div', 'li-div', 'foobar-div']], ['DIV', ['outer-div', 'li-div', 'foobar-div']], // case-insensitive in HTML ['div div', ['li-div']], ['div, div div', ['outer-div', 'li-div', 'foobar-div']], ['a[name]', ['name-anchor']], ['a[NAme]', ['name-anchor']], // case-insensitive in HTML: ['a[rel]', ['tag-anchor', 'nofollow-anchor']], ['a[rel="tag"]', ['tag-anchor']], ['a[href*="localhost"]', ['tag-anchor']], ['a[href*=""]', []], ['a[href^="http"]', ['tag-anchor', 'nofollow-anchor']], ['a[href^="http:"]', ['tag-anchor']], ['a[href^=""]', []], ['a[href$="org"]', ['nofollow-anchor']], ['a[href$=""]', []], ['div[foobar~="bc"]', ['foobar-div']], ['div[foobar~="cde"]', ['foobar-div']], ['[foobar~="ab bc"]', ['foobar-div']], ['[foobar~=""]', []], ['[foobar~=" \t"]', []], ['div[foobar~="cd"]', []], ['*[lang|="En"]', ['second-li']], ['[lang|="En-us"]', ['second-li']], // Attribute values are case-sensitive ['*[lang|="en"]', []], ['[lang|="en-US"]', []], ['*[lang|="e"]', []], // ... :lang() is not. [':lang("EN")', ['second-li', 'li-div']], ['*:lang(en-US)', ['second-li', 'li-div']], [':lang("e")', []], ['li:nth-child(3)', ['third-li']], ['li:nth-child(10)', []], ['li:nth-child(2n)', ['second-li', 'fourth-li', 'sixth-li']], ['li:nth-child(even)', ['second-li', 'fourth-li', 'sixth-li']], ['li:nth-child(2n+0)', ['second-li', 'fourth-li', 'sixth-li']], ['li:nth-child(+2n+1)', ['first-li', 'third-li', 'fifth-li', 'seventh-li']], ['li:nth-child(odd)', ['first-li', 'third-li', 'fifth-li', 'seventh-li']], ['li:nth-child(2n+4)', ['fourth-li', 'sixth-li']], ['li:nth-child(3n+1)', ['first-li', 'fourth-li', 'seventh-li']], ['li:nth-child(n)', ['first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li']], ['li:nth-child(n-1)', ['first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li']], ['li:nth-child(n+1)', ['first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li']], ['li:nth-child(n+3)', ['third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li']], ['li:nth-child(-n)', []], ['li:nth-child(-n-1)', []], ['li:nth-child(-n+1)', ['first-li']], ['li:nth-child(-n+3)', ['first-li', 'second-li', 'third-li']], ['li:nth-last-child(0)', []], ['li:nth-last-child(2n)', ['second-li', 'fourth-li', 'sixth-li']], ['li:nth-last-child(even)', ['second-li', 'fourth-li', 'sixth-li']], ['li:nth-last-child(2n+2)', ['second-li', 'fourth-li', 'sixth-li']], ['li:nth-last-child(n)', ['first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li']], ['li:nth-last-child(n-1)', ['first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li']], ['li:nth-last-child(n-3)', ['first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li']], ['li:nth-last-child(n+1)', ['first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li']], ['li:nth-last-child(n+3)', ['first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li']], ['li:nth-last-child(-n)', []], ['li:nth-last-child(-n-1)', []], ['li:nth-last-child(-n+1)', ['seventh-li']], ['li:nth-last-child(-n+3)', ['fifth-li', 'sixth-li', 'seventh-li']], ['ol:first-of-type', ['first-ol']], ['ol:nth-child(4)', ['first-ol']], ['ol:nth-of-type(2)', ['second-ol']], ['ol:nth-last-of-type(1)', ['second-ol']], ['span:only-child', ['foobar-span', 'no-siblings-of-any-type']], ['li div:only-child', ['li-div']], ['div *:only-child', ['li-div', 'foobar-span']], ['p:only-of-type', ['paragraph']], [':only-of-type', ['html', 'li-div', 'foobar-span', 'no-siblings-of-any-type']], ['div#foobar-div :only-of-type', ['foobar-span']], ['a:empty', ['name-anchor']], ['a:EMpty', ['name-anchor']], ['li:empty', ['third-li', 'fourth-li', 'fifth-li', 'sixth-li']], [':root', ['html']], ['html:root', ['html']], ['li:root', []], ['* :root', []], ['*:contains("link")', ['html', 'nil', 'outer-div', 'tag-anchor', 'nofollow-anchor']], [':CONtains("link")', ['html', 'nil', 'outer-div', 'tag-anchor', 'nofollow-anchor']], ['*:contains("LInk")', []], // case-sensitive ['*:contains("e")', ['html', 'nil', 'outer-div', 'first-ol', 'first-li', 'paragraph', 'p-em']], ['*:contains("E")', []], // case-sensitive ['.a', ['first-ol']], ['.b', ['first-ol']], ['*.a', ['first-ol']], ['ol.a', ['first-ol']], ['.c', ['first-ol', 'third-li', 'fourth-li']], ['*.c', ['first-ol', 'third-li', 'fourth-li']], ['ol *.c', ['third-li', 'fourth-li']], ['ol li.c', ['third-li', 'fourth-li']], ['li ~ li.c', ['third-li', 'fourth-li']], ['ol > li.c', ['third-li', 'fourth-li']], ['#first-li', ['first-li']], ['li#first-li', ['first-li']], ['*#first-li', ['first-li']], ['li div', ['li-div']], ['li > div', ['li-div']], ['div div', ['li-div']], ['div > div', []], ['div>.c', ['first-ol']], ['div > .c', ['first-ol']], ['div + div', ['foobar-div']], ['a ~ a', ['tag-anchor', 'nofollow-anchor']], ['a[rel="tag"] ~ a', ['nofollow-anchor']], ['ol#first-ol li:last-child', ['seventh-li']], ['ol#first-ol *:last-child', ['li-div', 'seventh-li']], ['#outer-div:first-child', ['outer-div']], ['#outer-div :first-child', ['name-anchor', 'first-li', 'li-div', 'p-b', 'checkbox-fieldset-disabled', 'area-href']], ['a[href]', ['tag-anchor', 'nofollow-anchor']], [':not(*)', []], ['a:not([href])', ['name-anchor']], ['ol :Not(li[class])', ['first-li', 'second-li', 'li-div', 'fifth-li', 'sixth-li', 'seventh-li']], [':is(#first-li, #second-li)', ['first-li', 'second-li']], ['a:is(#name-anchor, #tag-anchor)', ['name-anchor', 'tag-anchor']], [':is(.c)', ['first-ol', 'third-li', 'fourth-li']], ['a:is(:not(#name-anchor))', ['tag-anchor', 'nofollow-anchor']], ['a:not(:is(#name-anchor))', ['tag-anchor', 'nofollow-anchor']], [':where(#first-li, #second-li)', ['first-li', 'second-li']], ['a:where(#name-anchor, #tag-anchor)', ['name-anchor', 'tag-anchor']], [':where(.c)', ['first-ol', 'third-li', 'fourth-li']], ['a:where(:not(#name-anchor))', ['tag-anchor', 'nofollow-anchor']], ['a:not(:where(#name-anchor))', ['tag-anchor', 'nofollow-anchor']], ['a:where(:is(#name-anchor), :where(#tag-anchor))', ['name-anchor', 'tag-anchor']], // HTML-specific [':link', ['link-href', 'tag-anchor', 'nofollow-anchor', 'area-href']], [':visited', []], [':enabled', ['link-href', 'tag-anchor', 'nofollow-anchor', 'checkbox-unchecked', 'text-checked', 'checkbox-checked', 'area-href']], [':disabled', ['checkbox-disabled', 'checkbox-disabled-checked', 'fieldset', 'checkbox-fieldset-disabled']], [':checked', ['checkbox-checked', 'checkbox-disabled-checked']], ]; } public static function getHtmlShakespearTestData() { return [ ['*', 246], ['div:contains(CELIA)', 26], ['div:only-child', 22], // ? ['div:nth-child(even)', 106], ['div:nth-child(2n)', 106], ['div:nth-child(odd)', 137], ['div:nth-child(2n+1)', 137], ['div:nth-child(n)', 243], ['div:last-child', 53], ['div:first-child', 51], ['div > div', 242], ['div + div', 190], ['div ~ div', 190], ['body', 1], ['body div', 243], ['div', 243], ['div div', 242], ['div div div', 241], ['div, div, div', 243], ['div, a, span', 243], ['.dialog', 51], ['div.dialog', 51], ['div .dialog', 51], ['div.character, div.dialog', 99], ['div.direction.dialog', 0], ['div.dialog.direction', 0], ['div.dialog.scene', 1], ['div.scene.scene', 1], ['div.scene .scene', 0], ['div.direction .dialog ', 0], ['div .dialog .direction', 4], ['div.dialog .dialog .direction', 4], ['#speech5', 1], ['div#speech5', 1], ['div #speech5', 1], ['div.scene div.dialog', 49], ['div#scene1 div.dialog div', 142], ['#scene1 #speech1', 1], ['div[class]', 103], ['div[class=dialog]', 50], ['div[class^=dia]', 51], ['div[class$=log]', 50], ['div[class*=sce]', 1], ['div[class|=dialog]', 50], // ? Seems right ['div[class!=madeup]', 243], // ? Seems right ['div[class~=dialog]', 51], // ? Seems right [':scope > div', 1], [':scope > div > div[class=dialog]', 1], [':scope > div div', 242], ['div:is(div#test .dialog) .direction', 4], ]; } } ================================================ FILE: XPath/Extension/AbstractExtension.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; /** * XPath expression translator abstract extension. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon * * @internal */ abstract class AbstractExtension implements ExtensionInterface { public function getNodeTranslators(): array { return []; } public function getCombinationTranslators(): array { return []; } public function getFunctionTranslators(): array { return []; } public function getPseudoClassTranslators(): array { return []; } public function getAttributeMatchingTranslators(): array { return []; } public function getRelativeCombinationTranslators(): array { return []; } } ================================================ FILE: XPath/Extension/AttributeMatchingExtension.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; use Symfony\Component\CssSelector\XPath\Translator; use Symfony\Component\CssSelector\XPath\XPathExpr; /** * XPath expression translator attribute extension. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class AttributeMatchingExtension extends AbstractExtension { public function getAttributeMatchingTranslators(): array { return [ 'exists' => $this->translateExists(...), '=' => $this->translateEquals(...), '~=' => $this->translateIncludes(...), '|=' => $this->translateDashMatch(...), '^=' => $this->translatePrefixMatch(...), '$=' => $this->translateSuffixMatch(...), '*=' => $this->translateSubstringMatch(...), '!=' => $this->translateDifferent(...), ]; } public function translateExists(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr { return $xpath->addCondition($attribute); } public function translateEquals(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr { return $xpath->addCondition(\sprintf('%s = %s', $attribute, Translator::getXpathLiteral($value))); } public function translateIncludes(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr { return $xpath->addCondition($value ? \sprintf( '%1$s and contains(concat(\' \', normalize-space(%1$s), \' \'), %2$s)', $attribute, Translator::getXpathLiteral(' '.$value.' ') ) : '0'); } public function translateDashMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr { return $xpath->addCondition(\sprintf( '%1$s and (%1$s = %2$s or starts-with(%1$s, %3$s))', $attribute, Translator::getXpathLiteral($value), Translator::getXpathLiteral($value.'-') )); } public function translatePrefixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr { return $xpath->addCondition($value ? \sprintf( '%1$s and starts-with(%1$s, %2$s)', $attribute, Translator::getXpathLiteral($value) ) : '0'); } public function translateSuffixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr { return $xpath->addCondition($value ? \sprintf( '%1$s and substring(%1$s, string-length(%1$s)-%2$s) = %3$s', $attribute, \strlen($value) - 1, Translator::getXpathLiteral($value) ) : '0'); } public function translateSubstringMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr { return $xpath->addCondition($value ? \sprintf( '%1$s and contains(%1$s, %2$s)', $attribute, Translator::getXpathLiteral($value) ) : '0'); } public function translateDifferent(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr { return $xpath->addCondition(\sprintf( $value ? 'not(%1$s) or %1$s != %2$s' : '%s != %s', $attribute, Translator::getXpathLiteral($value) )); } public function getName(): string { return 'attribute-matching'; } } ================================================ FILE: XPath/Extension/CombinationExtension.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; use Symfony\Component\CssSelector\XPath\XPathExpr; /** * XPath expression translator combination extension. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class CombinationExtension extends AbstractExtension { public function getCombinationTranslators(): array { return [ ' ' => $this->translateDescendant(...), '>' => $this->translateChild(...), '+' => $this->translateDirectAdjacent(...), '~' => $this->translateIndirectAdjacent(...), ]; } public function translateDescendant(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr { return $xpath->join('/descendant-or-self::*/', $combinedXpath); } public function translateChild(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr { return $xpath->join('/', $combinedXpath); } public function translateDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr { return $xpath ->join('/following-sibling::', $combinedXpath) ->addNameTest() ->addCondition('position() = 1'); } public function translateIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr { return $xpath->join('/following-sibling::', $combinedXpath); } public function getName(): string { return 'combination'; } } ================================================ FILE: XPath/Extension/ExtensionInterface.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; use Symfony\Component\CssSelector\XPath\XPathExpr; /** * XPath expression translator extension interface. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon * * @internal */ interface ExtensionInterface { /** * Returns node translators. * * These callables will receive the node as first argument and the translator as second argument. * * @return callable[] */ public function getNodeTranslators(): array; /** * Returns combination translators. * * @return callable[] */ public function getCombinationTranslators(): array; /** * Returns function translators. * * @return callable[] */ public function getFunctionTranslators(): array; /** * Returns pseudo-class translators. * * @return callable[] */ public function getPseudoClassTranslators(): array; /** * Returns attribute operation translators. * * @return callable[] */ public function getAttributeMatchingTranslators(): array; /** * Returns combination translators found inside ":has()" relation. * * @return array */ public function getRelativeCombinationTranslators(): array; /** * Returns extension name. */ public function getName(): string; } ================================================ FILE: XPath/Extension/FunctionExtension.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; use Symfony\Component\CssSelector\Exception\ExpressionErrorException; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; use Symfony\Component\CssSelector\Node\FunctionNode; use Symfony\Component\CssSelector\Parser\Parser; use Symfony\Component\CssSelector\XPath\Translator; use Symfony\Component\CssSelector\XPath\XPathExpr; /** * XPath expression translator function extension. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class FunctionExtension extends AbstractExtension { public function getFunctionTranslators(): array { return [ 'nth-child' => $this->translateNthChild(...), 'nth-last-child' => $this->translateNthLastChild(...), 'nth-of-type' => $this->translateNthOfType(...), 'nth-last-of-type' => $this->translateNthLastOfType(...), 'contains' => $this->translateContains(...), 'lang' => $this->translateLang(...), ]; } /** * @throws ExpressionErrorException */ public function translateNthChild(XPathExpr $xpath, FunctionNode $function, bool $last = false, bool $addNameTest = true): XPathExpr { try { [$a, $b] = Parser::parseSeries($function->getArguments()); } catch (SyntaxErrorException $e) { throw new ExpressionErrorException(\sprintf('Invalid series: "%s".', implode('", "', $function->getArguments())), 0, $e); } $xpath->addStarPrefix(); if ($addNameTest) { $xpath->addNameTest(); } if (0 === $a) { return $xpath->addCondition('position() = '.($last ? 'last() - '.($b - 1) : $b)); } if ($a < 0) { if ($b < 1) { return $xpath->addCondition('false()'); } $sign = '<='; } else { $sign = '>='; } $expr = 'position()'; if ($last) { $expr = 'last() - '.$expr; --$b; } if (0 !== $b) { $expr .= ' - '.$b; } $conditions = [\sprintf('%s %s 0', $expr, $sign)]; if (1 !== $a && -1 !== $a) { $conditions[] = \sprintf('(%s) mod %d = 0', $expr, $a); } return $xpath->addCondition(implode(' and ', $conditions)); // todo: handle an+b, odd, even // an+b means every-a, plus b, e.g., 2n+1 means odd // 0n+b means b // n+0 means a=1, i.e., all elements // an means every a elements, i.e., 2n means even // -n means -1n // -1n+6 means elements 6 and previous } public function translateNthLastChild(XPathExpr $xpath, FunctionNode $function): XPathExpr { return $this->translateNthChild($xpath, $function, true); } public function translateNthOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr { return $this->translateNthChild($xpath, $function, false, false); } /** * @throws ExpressionErrorException */ public function translateNthLastOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr { if ('*' === $xpath->getElement()) { throw new ExpressionErrorException('"*:nth-of-type()" is not implemented.'); } return $this->translateNthChild($xpath, $function, true, false); } /** * @throws ExpressionErrorException */ public function translateContains(XPathExpr $xpath, FunctionNode $function): XPathExpr { $arguments = $function->getArguments(); foreach ($arguments as $token) { if (!($token->isString() || $token->isIdentifier())) { throw new ExpressionErrorException('Expected a single string or identifier for :contains(), got '.implode(', ', $arguments)); } } return $xpath->addCondition(\sprintf( 'contains(string(.), %s)', Translator::getXpathLiteral($arguments[0]->getValue()) )); } /** * @throws ExpressionErrorException */ public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr { $arguments = $function->getArguments(); foreach ($arguments as $token) { if (!($token->isString() || $token->isIdentifier())) { throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments)); } } return $xpath->addCondition(\sprintf( 'lang(%s)', Translator::getXpathLiteral($arguments[0]->getValue()) )); } public function getName(): string { return 'function'; } } ================================================ FILE: XPath/Extension/HtmlExtension.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; use Symfony\Component\CssSelector\Exception\ExpressionErrorException; use Symfony\Component\CssSelector\Node\FunctionNode; use Symfony\Component\CssSelector\XPath\Translator; use Symfony\Component\CssSelector\XPath\XPathExpr; /** * XPath expression translator HTML extension. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class HtmlExtension extends AbstractExtension { public function __construct(Translator $translator) { $translator ->getExtension('node') ->setFlag(NodeExtension::ELEMENT_NAME_IN_LOWER_CASE, true) ->setFlag(NodeExtension::ATTRIBUTE_NAME_IN_LOWER_CASE, true); } public function getPseudoClassTranslators(): array { return [ 'checked' => $this->translateChecked(...), 'link' => $this->translateLink(...), 'disabled' => $this->translateDisabled(...), 'enabled' => $this->translateEnabled(...), 'selected' => $this->translateSelected(...), 'invalid' => $this->translateInvalid(...), 'hover' => $this->translateHover(...), 'visited' => $this->translateVisited(...), ]; } public function getFunctionTranslators(): array { return [ 'lang' => $this->translateLang(...), ]; } public function translateChecked(XPathExpr $xpath): XPathExpr { return $xpath->addCondition( '(@checked ' ."and (name(.) = 'input' or name(.) = 'command')" ."and (@type = 'checkbox' or @type = 'radio'))" ); } public function translateLink(XPathExpr $xpath): XPathExpr { return $xpath->addCondition("@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')"); } public function translateDisabled(XPathExpr $xpath): XPathExpr { return $xpath->addCondition( '(' .'@disabled and' .'(' ."(name(.) = 'input' and @type != 'hidden')" ." or name(.) = 'button'" ." or name(.) = 'select'" ." or name(.) = 'textarea'" ." or name(.) = 'command'" ." or name(.) = 'fieldset'" ." or name(.) = 'optgroup'" ." or name(.) = 'option'" .')' .') or (' ."(name(.) = 'input' and @type != 'hidden')" ." or name(.) = 'button'" ." or name(.) = 'select'" ." or name(.) = 'textarea'" .')' .' and ancestor::fieldset[@disabled]' ); // todo: in the second half, add "and is not a descendant of that fieldset element's first legend element child, if any." } public function translateEnabled(XPathExpr $xpath): XPathExpr { return $xpath->addCondition( '(' .'@href and (' ."name(.) = 'a'" ." or name(.) = 'link'" ." or name(.) = 'area'" .')' .') or (' .'(' ."name(.) = 'command'" ." or name(.) = 'fieldset'" ." or name(.) = 'optgroup'" .')' .' and not(@disabled)' .') or (' .'(' ."(name(.) = 'input' and @type != 'hidden')" ." or name(.) = 'button'" ." or name(.) = 'select'" ." or name(.) = 'textarea'" ." or name(.) = 'keygen'" .')' .' and not (@disabled or ancestor::fieldset[@disabled])' .') or (' ."name(.) = 'option' and not(" .'@disabled or ancestor::optgroup[@disabled]' .')' .')' ); } /** * @throws ExpressionErrorException */ public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr { $arguments = $function->getArguments(); foreach ($arguments as $token) { if (!($token->isString() || $token->isIdentifier())) { throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments)); } } return $xpath->addCondition(\sprintf( 'ancestor-or-self::*[@lang][1][starts-with(concat(' ."translate(@%s, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '-')" .', %s)]', 'lang', Translator::getXpathLiteral(strtolower($arguments[0]->getValue()).'-') )); } public function translateSelected(XPathExpr $xpath): XPathExpr { return $xpath->addCondition("(@selected and name(.) = 'option')"); } public function translateInvalid(XPathExpr $xpath): XPathExpr { return $xpath->addCondition('0'); } public function translateHover(XPathExpr $xpath): XPathExpr { return $xpath->addCondition('0'); } public function translateVisited(XPathExpr $xpath): XPathExpr { return $xpath->addCondition('0'); } public function getName(): string { return 'html'; } } ================================================ FILE: XPath/Extension/NodeExtension.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; use Symfony\Component\CssSelector\Node; use Symfony\Component\CssSelector\XPath\Translator; use Symfony\Component\CssSelector\XPath\XPathExpr; /** * XPath expression translator node extension. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon * * @internal */ class NodeExtension extends AbstractExtension { public const ELEMENT_NAME_IN_LOWER_CASE = 1; public const ATTRIBUTE_NAME_IN_LOWER_CASE = 2; public const ATTRIBUTE_VALUE_IN_LOWER_CASE = 4; public function __construct( private int $flags = 0, ) { } /** * @return $this */ public function setFlag(int $flag, bool $on): static { if ($on && !$this->hasFlag($flag)) { $this->flags += $flag; } if (!$on && $this->hasFlag($flag)) { $this->flags -= $flag; } return $this; } public function hasFlag(int $flag): bool { return (bool) ($this->flags & $flag); } public function getNodeTranslators(): array { return [ 'Selector' => $this->translateSelector(...), 'CombinedSelector' => $this->translateCombinedSelector(...), 'Negation' => $this->translateNegation(...), 'Matching' => $this->translateMatching(...), 'SpecificityAdjustment' => $this->translateSpecificityAdjustment(...), 'Function' => $this->translateFunction(...), 'Pseudo' => $this->translatePseudo(...), 'Attribute' => $this->translateAttribute(...), 'Class' => $this->translateClass(...), 'Hash' => $this->translateHash(...), 'Element' => $this->translateElement(...), 'Relation' => $this->translateRelation(...), ]; } public function translateSelector(Node\SelectorNode $node, Translator $translator): XPathExpr { return $translator->nodeToXPath($node->getTree()); } public function translateCombinedSelector(Node\CombinedSelectorNode $node, Translator $translator): XPathExpr { return $translator->addCombination($node->getCombinator(), $node->getSelector(), $node->getSubSelector()); } public function translateNegation(Node\NegationNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->getSelector()); $subXpath = $translator->nodeToXPath($node->getSubSelector()); $subXpath->addNameTest(); if ($subXpath->getCondition()) { return $xpath->addCondition(\sprintf('not(%s)', $subXpath->getCondition())); } return $xpath->addCondition('0'); } public function translateMatching(Node\MatchingNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->selector); foreach ($node->arguments as $argument) { $expr = $translator->nodeToXPath($argument); $expr->addNameTest(); if ($condition = $expr->getCondition()) { $xpath->addCondition($condition, 'or'); } } return $xpath; } public function translateSpecificityAdjustment(Node\SpecificityAdjustmentNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->selector); foreach ($node->arguments as $argument) { $expr = $translator->nodeToXPath($argument); $expr->addNameTest(); if ($condition = $expr->getCondition()) { $xpath->addCondition($condition, 'or'); } } return $xpath; } public function translateFunction(Node\FunctionNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->getSelector()); return $translator->addFunction($xpath, $node); } public function translatePseudo(Node\PseudoNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->getSelector()); return $translator->addPseudoClass($xpath, $node->getIdentifier()); } public function translateAttribute(Node\AttributeNode $node, Translator $translator): XPathExpr { $name = $node->getAttribute(); $safe = $this->isSafeName($name); if ($this->hasFlag(self::ATTRIBUTE_NAME_IN_LOWER_CASE)) { $name = strtolower($name); } if ($node->getNamespace()) { $name = \sprintf('%s:%s', $node->getNamespace(), $name); $safe = $safe && $this->isSafeName($node->getNamespace()); } $attribute = $safe ? '@'.$name : \sprintf('attribute::*[name() = %s]', Translator::getXpathLiteral($name)); $value = $node->getValue(); $xpath = $translator->nodeToXPath($node->getSelector()); if ($this->hasFlag(self::ATTRIBUTE_VALUE_IN_LOWER_CASE)) { $value = strtolower($value); } return $translator->addAttributeMatching($xpath, $node->getOperator(), $attribute, $value); } public function translateClass(Node\ClassNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->getSelector()); return $translator->addAttributeMatching($xpath, '~=', '@class', $node->getName()); } public function translateHash(Node\HashNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->getSelector()); return $translator->addAttributeMatching($xpath, '=', '@id', $node->getId()); } public function translateElement(Node\ElementNode $node): XPathExpr { $element = $node->getElement(); if ($element && $this->hasFlag(self::ELEMENT_NAME_IN_LOWER_CASE)) { $element = strtolower($element); } if ($element) { $safe = $this->isSafeName($element); } else { $element = '*'; $safe = true; } if ($node->getNamespace()) { $element = \sprintf('%s:%s', $node->getNamespace(), $element); $safe = $safe && $this->isSafeName($node->getNamespace()); } $xpath = new XPathExpr('', $element); if (!$safe) { $xpath->addNameTest(); } return $xpath; } public function translateRelation(Node\RelationNode $node, Translator $translator): XPathExpr { $combinator = $node->getCombinator(); return $translator->addRelativeCombination($combinator, $node->getSelector(), $node->getSubSelector()); } public function getName(): string { return 'node'; } private function isSafeName(string $name): bool { return 0 < preg_match('~^[a-zA-Z_][a-zA-Z0-9_.-]*$~', $name); } } ================================================ FILE: XPath/Extension/PseudoClassExtension.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; use Symfony\Component\CssSelector\Exception\ExpressionErrorException; use Symfony\Component\CssSelector\XPath\XPathExpr; /** * XPath expression translator pseudo-class extension. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class PseudoClassExtension extends AbstractExtension { public function getPseudoClassTranslators(): array { return [ 'root' => $this->translateRoot(...), 'scope' => $this->translateScopePseudo(...), 'first-child' => $this->translateFirstChild(...), 'last-child' => $this->translateLastChild(...), 'first-of-type' => $this->translateFirstOfType(...), 'last-of-type' => $this->translateLastOfType(...), 'only-child' => $this->translateOnlyChild(...), 'only-of-type' => $this->translateOnlyOfType(...), 'empty' => $this->translateEmpty(...), ]; } public function translateRoot(XPathExpr $xpath): XPathExpr { return $xpath->addCondition('not(parent::*)'); } public function translateScopePseudo(XPathExpr $xpath): XPathExpr { return $xpath->addCondition('1'); } public function translateFirstChild(XPathExpr $xpath): XPathExpr { return $xpath ->addStarPrefix() ->addNameTest() ->addCondition('position() = 1'); } public function translateLastChild(XPathExpr $xpath): XPathExpr { return $xpath ->addStarPrefix() ->addNameTest() ->addCondition('position() = last()'); } /** * @throws ExpressionErrorException */ public function translateFirstOfType(XPathExpr $xpath): XPathExpr { if ('*' === $xpath->getElement()) { throw new ExpressionErrorException('"*:first-of-type" is not implemented.'); } return $xpath ->addStarPrefix() ->addCondition('position() = 1'); } /** * @throws ExpressionErrorException */ public function translateLastOfType(XPathExpr $xpath): XPathExpr { if ('*' === $xpath->getElement()) { throw new ExpressionErrorException('"*:last-of-type" is not implemented.'); } return $xpath ->addStarPrefix() ->addCondition('position() = last()'); } public function translateOnlyChild(XPathExpr $xpath): XPathExpr { return $xpath ->addStarPrefix() ->addNameTest() ->addCondition('last() = 1'); } public function translateOnlyOfType(XPathExpr $xpath): XPathExpr { $element = $xpath->getElement(); return $xpath->addCondition(\sprintf('count(preceding-sibling::%s)=0 and count(following-sibling::%s)=0', $element, $element)); } public function translateEmpty(XPathExpr $xpath): XPathExpr { return $xpath->addCondition('not(*) and not(string-length())'); } public function getName(): string { return 'pseudo-class'; } } ================================================ FILE: XPath/Extension/RelationExtension.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath\Extension; use Symfony\Component\CssSelector\XPath\XPathExpr; /** * XPath expression translator combination extension. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Franck Ranaivo-Harisoa * * @internal */ class RelationExtension extends AbstractExtension { public function getRelativeCombinationTranslators(): array { return [ ' ' => $this->translateRelationDescendant(...), '>' => $this->translateRelationChild(...), '+' => $this->translateRelationDirectAdjacent(...), '~' => $this->translateRelationIndirectAdjacent(...), ]; } public function translateRelationDescendant(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr { return $xpath->join('[descendant-or-self::', $combinedXpath, ']', true); } public function translateRelationChild(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr { return $xpath->join('[./', $combinedXpath, ']', true); } public function translateRelationDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr { $combinedXpath ->addNameTest() ->addCondition('position() = 1'); return $xpath ->join('[following-sibling::', $combinedXpath, ']', true); } public function translateRelationIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr { return $xpath->join('[following-sibling::', $combinedXpath, ']', true); } public function getName(): string { return 'relation'; } } ================================================ FILE: XPath/Translator.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath; use Symfony\Component\CssSelector\Exception\ExpressionErrorException; use Symfony\Component\CssSelector\Node\FunctionNode; use Symfony\Component\CssSelector\Node\NodeInterface; use Symfony\Component\CssSelector\Node\SelectorNode; use Symfony\Component\CssSelector\Parser\Parser; use Symfony\Component\CssSelector\Parser\ParserInterface; /** * XPath expression translator interface. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class Translator implements TranslatorInterface { private ParserInterface $mainParser; /** * @var ParserInterface[] */ private array $shortcutParsers = []; /** * @var Extension\ExtensionInterface[] */ private array $extensions = []; private array $nodeTranslators = []; private array $combinationTranslators = []; private array $relativeCombinationTranslators = []; private array $functionTranslators = []; private array $pseudoClassTranslators = []; private array $attributeMatchingTranslators = []; public function __construct(?ParserInterface $parser = null) { $this->mainParser = $parser ?? new Parser(); $this ->registerExtension(new Extension\NodeExtension()) ->registerExtension(new Extension\CombinationExtension()) ->registerExtension(new Extension\FunctionExtension()) ->registerExtension(new Extension\PseudoClassExtension()) ->registerExtension(new Extension\AttributeMatchingExtension()) ->registerExtension(new Extension\RelationExtension()) ; } public static function getXpathLiteral(string $element): string { if (!str_contains($element, "'")) { return "'".$element."'"; } if (!str_contains($element, '"')) { return '"'.$element.'"'; } $string = $element; $parts = []; while (true) { if (false !== $pos = strpos($string, "'")) { $parts[] = \sprintf("'%s'", substr($string, 0, $pos)); $parts[] = "\"'\""; $string = substr($string, $pos + 1); } else { $parts[] = "'$string'"; break; } } return \sprintf('concat(%s)', implode(', ', $parts)); } public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string { $selectors = $this->parseSelectors($cssExpr); foreach ($selectors as $index => $selector) { if (null !== $selector->getPseudoElement()) { throw new ExpressionErrorException('Pseudo-elements are not supported.'); } $selectors[$index] = $this->selectorToXPath($selector, $prefix); } return implode(' | ', $selectors); } public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string { return ($prefix ?: '').$this->nodeToXPath($selector); } /** * @return $this */ public function registerExtension(Extension\ExtensionInterface $extension): static { $this->extensions[$extension->getName()] = $extension; $this->nodeTranslators = array_merge($this->nodeTranslators, $extension->getNodeTranslators()); $this->combinationTranslators = array_merge($this->combinationTranslators, $extension->getCombinationTranslators()); $this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators()); $this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators()); $this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators()); $this->relativeCombinationTranslators = array_merge($this->relativeCombinationTranslators, $extension->getRelativeCombinationTranslators()); return $this; } /** * @throws ExpressionErrorException */ public function getExtension(string $name): Extension\ExtensionInterface { if (!isset($this->extensions[$name])) { throw new ExpressionErrorException(\sprintf('Extension "%s" not registered.', $name)); } return $this->extensions[$name]; } /** * @return $this */ public function registerParserShortcut(ParserInterface $shortcut): static { $this->shortcutParsers[] = $shortcut; return $this; } /** * @throws ExpressionErrorException */ public function nodeToXPath(NodeInterface $node): XPathExpr { if (!isset($this->nodeTranslators[$node->getNodeName()])) { throw new ExpressionErrorException(\sprintf('Node "%s" not supported.', $node->getNodeName())); } return $this->nodeTranslators[$node->getNodeName()]($node, $this); } /** * @throws ExpressionErrorException */ public function addCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr { if (!isset($this->combinationTranslators[$combiner])) { throw new ExpressionErrorException(\sprintf('Combiner "%s" not supported.', $combiner)); } return $this->combinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath)); } /** * @throws ExpressionErrorException */ public function addRelativeCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr { if (!isset($this->relativeCombinationTranslators[$combiner])) { throw new ExpressionErrorException(\sprintf('Combiner "%s" not supported.', $combiner)); } return $this->relativeCombinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath)); } /** * @throws ExpressionErrorException */ public function addFunction(XPathExpr $xpath, FunctionNode $function): XPathExpr { if (!isset($this->functionTranslators[$function->getName()])) { throw new ExpressionErrorException(\sprintf('Function "%s" not supported.', $function->getName())); } return $this->functionTranslators[$function->getName()]($xpath, $function); } /** * @throws ExpressionErrorException */ public function addPseudoClass(XPathExpr $xpath, string $pseudoClass): XPathExpr { if (!isset($this->pseudoClassTranslators[$pseudoClass])) { throw new ExpressionErrorException(\sprintf('Pseudo-class "%s" not supported.', $pseudoClass)); } return $this->pseudoClassTranslators[$pseudoClass]($xpath); } /** * @throws ExpressionErrorException */ public function addAttributeMatching(XPathExpr $xpath, string $operator, string $attribute, ?string $value): XPathExpr { if (!isset($this->attributeMatchingTranslators[$operator])) { throw new ExpressionErrorException(\sprintf('Attribute matcher operator "%s" not supported.', $operator)); } return $this->attributeMatchingTranslators[$operator]($xpath, $attribute, $value); } /** * @return SelectorNode[] */ private function parseSelectors(string $css): array { foreach ($this->shortcutParsers as $shortcut) { $tokens = $shortcut->parse($css); if ($tokens) { return $tokens; } } return $this->mainParser->parse($css); } } ================================================ FILE: XPath/TranslatorInterface.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath; use Symfony\Component\CssSelector\Node\SelectorNode; /** * XPath expression translator interface. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ interface TranslatorInterface { /** * Translates a CSS selector to an XPath expression. */ public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string; /** * Translates a parsed selector node to an XPath expression. */ public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string; } ================================================ FILE: XPath/XPathExpr.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\CssSelector\XPath; /** * XPath expression translator interface. * * This component is a port of the Python cssselect library, * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Jean-François Simon * * @internal */ class XPathExpr { public function __construct( private string $path = '', private string $element = '*', private string $condition = '', bool $starPrefix = false, ) { if ($starPrefix) { $this->addStarPrefix(); } } public function getElement(): string { return $this->element; } /** * @return $this */ public function addCondition(string $condition, string $operator = 'and'): static { $this->condition = $this->condition ? \sprintf('(%s) %s (%s)', $this->condition, $operator, $condition) : $condition; return $this; } public function getCondition(): string { return $this->condition; } /** * @return $this */ public function addNameTest(): static { if ('*' !== $this->element) { $this->addCondition('name() = '.Translator::getXpathLiteral($this->element)); $this->element = '*'; } return $this; } /** * @return $this */ public function addStarPrefix(): static { $this->path .= '*/'; return $this; } /** * Joins another XPathExpr with a combiner. * * @return $this */ public function join(string $combiner, self $expr, ?string $closingCombiner = null, bool $hasInnerConditions = false): static { $path = $this->__toString().$combiner; if ('*/' !== $expr->path) { $path .= $expr->path; } $this->path = $path; if (!$hasInnerConditions) { $this->element = $expr->element.($closingCombiner ?? ''); $this->condition = $expr->condition; } else { $this->element = $expr->element; if ($expr->condition) { $this->element .= '['.$expr->condition.']'; } if ($closingCombiner) { $this->element .= $closingCombiner; } } return $this; } public function __toString(): string { $path = $this->path.$this->element; $condition = '' === $this->condition ? '' : '['.$this->condition.']'; return $path.$condition; } } ================================================ FILE: composer.json ================================================ { "name": "symfony/css-selector", "type": "library", "description": "Converts CSS selectors to XPath expressions", "keywords": [], "homepage": "https://symfony.com", "license": "MIT", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com" }, { "name": "Jean-François Simon", "email": "jeanfrancois.simon@sensiolabs.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], "require": { "php": ">=8.4" }, "autoload": { "psr-4": { "Symfony\\Component\\CssSelector\\": "" }, "exclude-from-classmap": [ "/Tests/" ] }, "minimum-stability": "dev" } ================================================ FILE: phpunit.xml.dist ================================================ ./Tests/ ./ ./Resources ./Tests ./vendor