Chris@0: Chris@0: * Chris@0: * For the full copyright and license information, please view the LICENSE Chris@0: * file that was distributed with this source code. Chris@0: */ Chris@0: Chris@0: namespace Symfony\Component\CssSelector\Parser; Chris@0: Chris@0: use Symfony\Component\CssSelector\Exception\SyntaxErrorException; Chris@0: use Symfony\Component\CssSelector\Node; Chris@0: use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer; Chris@0: Chris@0: /** Chris@0: * CSS selector parser. Chris@0: * Chris@0: * This component is a port of the Python cssselect library, Chris@0: * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. Chris@0: * Chris@0: * @author Jean-François Simon Chris@0: * Chris@0: * @internal Chris@0: */ Chris@0: class Parser implements ParserInterface Chris@0: { Chris@0: private $tokenizer; Chris@0: Chris@0: public function __construct(Tokenizer $tokenizer = null) Chris@0: { Chris@0: $this->tokenizer = $tokenizer ?: new Tokenizer(); Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function parse($source) Chris@0: { Chris@0: $reader = new Reader($source); Chris@0: $stream = $this->tokenizer->tokenize($reader); Chris@0: Chris@0: return $this->parseSelectorList($stream); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses the arguments for ":nth-child()" and friends. Chris@0: * Chris@0: * @param Token[] $tokens Chris@0: * Chris@0: * @return array Chris@0: * Chris@0: * @throws SyntaxErrorException Chris@0: */ Chris@0: public static function parseSeries(array $tokens) Chris@0: { Chris@0: foreach ($tokens as $token) { Chris@0: if ($token->isString()) { Chris@0: throw SyntaxErrorException::stringAsFunctionArgument(); Chris@0: } Chris@0: } Chris@0: Chris@0: $joined = trim(implode('', array_map(function (Token $token) { Chris@0: return $token->getValue(); Chris@0: }, $tokens))); Chris@0: Chris@0: $int = function ($string) { Chris@0: if (!is_numeric($string)) { Chris@0: throw SyntaxErrorException::stringAsFunctionArgument(); Chris@0: } Chris@0: Chris@0: return (int) $string; Chris@0: }; Chris@0: Chris@0: switch (true) { Chris@0: case 'odd' === $joined: Chris@17: return [2, 1]; Chris@0: case 'even' === $joined: Chris@17: return [2, 0]; Chris@0: case 'n' === $joined: Chris@17: return [1, 0]; Chris@0: case false === strpos($joined, 'n'): Chris@17: return [0, $int($joined)]; Chris@0: } Chris@0: Chris@0: $split = explode('n', $joined); Chris@0: $first = isset($split[0]) ? $split[0] : null; Chris@0: Chris@17: return [ Chris@0: $first ? ('-' === $first || '+' === $first ? $int($first.'1') : $int($first)) : 1, Chris@0: isset($split[1]) && $split[1] ? $int($split[1]) : 0, Chris@17: ]; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses selector nodes. Chris@0: * Chris@0: * @return array Chris@0: */ Chris@0: private function parseSelectorList(TokenStream $stream) Chris@0: { Chris@0: $stream->skipWhitespace(); Chris@17: $selectors = []; Chris@0: Chris@0: while (true) { Chris@0: $selectors[] = $this->parserSelectorNode($stream); Chris@0: Chris@17: if ($stream->getPeek()->isDelimiter([','])) { Chris@0: $stream->getNext(); Chris@0: $stream->skipWhitespace(); Chris@0: } else { Chris@0: break; Chris@0: } Chris@0: } Chris@0: Chris@0: return $selectors; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses next selector or combined node. Chris@0: * Chris@0: * @return Node\SelectorNode Chris@0: * Chris@0: * @throws SyntaxErrorException Chris@0: */ Chris@0: private function parserSelectorNode(TokenStream $stream) Chris@0: { Chris@0: list($result, $pseudoElement) = $this->parseSimpleSelector($stream); Chris@0: Chris@0: while (true) { Chris@0: $stream->skipWhitespace(); Chris@0: $peek = $stream->getPeek(); Chris@0: Chris@17: if ($peek->isFileEnd() || $peek->isDelimiter([','])) { Chris@0: break; Chris@0: } Chris@0: Chris@0: if (null !== $pseudoElement) { Chris@0: throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector'); Chris@0: } Chris@0: Chris@17: if ($peek->isDelimiter(['+', '>', '~'])) { Chris@0: $combinator = $stream->getNext()->getValue(); Chris@0: $stream->skipWhitespace(); Chris@0: } else { Chris@0: $combinator = ' '; Chris@0: } Chris@0: Chris@0: list($nextSelector, $pseudoElement) = $this->parseSimpleSelector($stream); Chris@0: $result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector); Chris@0: } Chris@0: Chris@0: return new Node\SelectorNode($result, $pseudoElement); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses next simple node (hash, class, pseudo, negation). Chris@0: * Chris@0: * @param TokenStream $stream Chris@0: * @param bool $insideNegation Chris@0: * Chris@0: * @return array Chris@0: * Chris@0: * @throws SyntaxErrorException Chris@0: */ Chris@0: private function parseSimpleSelector(TokenStream $stream, $insideNegation = false) Chris@0: { Chris@0: $stream->skipWhitespace(); Chris@0: Chris@17: $selectorStart = \count($stream->getUsed()); Chris@0: $result = $this->parseElementNode($stream); Chris@0: $pseudoElement = null; Chris@0: Chris@0: while (true) { Chris@0: $peek = $stream->getPeek(); Chris@0: if ($peek->isWhitespace() Chris@0: || $peek->isFileEnd() Chris@17: || $peek->isDelimiter([',', '+', '>', '~']) Chris@17: || ($insideNegation && $peek->isDelimiter([')'])) Chris@0: ) { Chris@0: break; Chris@0: } Chris@0: Chris@0: if (null !== $pseudoElement) { Chris@0: throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector'); Chris@0: } Chris@0: Chris@0: if ($peek->isHash()) { Chris@0: $result = new Node\HashNode($result, $stream->getNext()->getValue()); Chris@17: } elseif ($peek->isDelimiter(['.'])) { Chris@0: $stream->getNext(); Chris@0: $result = new Node\ClassNode($result, $stream->getNextIdentifier()); Chris@17: } elseif ($peek->isDelimiter(['['])) { Chris@0: $stream->getNext(); Chris@0: $result = $this->parseAttributeNode($result, $stream); Chris@17: } elseif ($peek->isDelimiter([':'])) { Chris@0: $stream->getNext(); Chris@0: Chris@17: if ($stream->getPeek()->isDelimiter([':'])) { Chris@0: $stream->getNext(); Chris@0: $pseudoElement = $stream->getNextIdentifier(); Chris@0: Chris@0: continue; Chris@0: } Chris@0: Chris@0: $identifier = $stream->getNextIdentifier(); Chris@17: if (\in_array(strtolower($identifier), ['first-line', 'first-letter', 'before', 'after'])) { Chris@0: // Special case: CSS 2.1 pseudo-elements can have a single ':'. Chris@0: // Any new pseudo-element must have two. Chris@0: $pseudoElement = $identifier; Chris@0: Chris@0: continue; Chris@0: } Chris@0: Chris@17: if (!$stream->getPeek()->isDelimiter(['('])) { Chris@0: $result = new Node\PseudoNode($result, $identifier); Chris@0: Chris@0: continue; Chris@0: } Chris@0: Chris@0: $stream->getNext(); Chris@0: $stream->skipWhitespace(); Chris@0: Chris@0: if ('not' === strtolower($identifier)) { Chris@0: if ($insideNegation) { Chris@0: throw SyntaxErrorException::nestedNot(); Chris@0: } Chris@0: Chris@0: list($argument, $argumentPseudoElement) = $this->parseSimpleSelector($stream, true); Chris@0: $next = $stream->getNext(); Chris@0: Chris@0: if (null !== $argumentPseudoElement) { Chris@0: throw SyntaxErrorException::pseudoElementFound($argumentPseudoElement, 'inside ::not()'); Chris@0: } Chris@0: Chris@17: if (!$next->isDelimiter([')'])) { Chris@0: throw SyntaxErrorException::unexpectedToken('")"', $next); Chris@0: } Chris@0: Chris@0: $result = new Node\NegationNode($result, $argument); Chris@0: } else { Chris@17: $arguments = []; Chris@0: $next = null; Chris@0: Chris@0: while (true) { Chris@0: $stream->skipWhitespace(); Chris@0: $next = $stream->getNext(); Chris@0: Chris@0: if ($next->isIdentifier() Chris@0: || $next->isString() Chris@0: || $next->isNumber() Chris@17: || $next->isDelimiter(['+', '-']) Chris@0: ) { Chris@0: $arguments[] = $next; Chris@17: } elseif ($next->isDelimiter([')'])) { Chris@0: break; Chris@0: } else { Chris@0: throw SyntaxErrorException::unexpectedToken('an argument', $next); Chris@0: } Chris@0: } Chris@0: Chris@0: if (empty($arguments)) { Chris@0: throw SyntaxErrorException::unexpectedToken('at least one argument', $next); Chris@0: } Chris@0: Chris@0: $result = new Node\FunctionNode($result, $identifier, $arguments); Chris@0: } Chris@0: } else { Chris@0: throw SyntaxErrorException::unexpectedToken('selector', $peek); Chris@0: } Chris@0: } Chris@0: Chris@17: if (\count($stream->getUsed()) === $selectorStart) { Chris@0: throw SyntaxErrorException::unexpectedToken('selector', $stream->getPeek()); Chris@0: } Chris@0: Chris@17: return [$result, $pseudoElement]; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses next element node. Chris@0: * Chris@0: * @return Node\ElementNode Chris@0: */ Chris@0: private function parseElementNode(TokenStream $stream) Chris@0: { Chris@0: $peek = $stream->getPeek(); Chris@0: Chris@17: if ($peek->isIdentifier() || $peek->isDelimiter(['*'])) { Chris@0: if ($peek->isIdentifier()) { Chris@0: $namespace = $stream->getNext()->getValue(); Chris@0: } else { Chris@0: $stream->getNext(); Chris@0: $namespace = null; Chris@0: } Chris@0: Chris@17: if ($stream->getPeek()->isDelimiter(['|'])) { Chris@0: $stream->getNext(); Chris@0: $element = $stream->getNextIdentifierOrStar(); Chris@0: } else { Chris@0: $element = $namespace; Chris@0: $namespace = null; Chris@0: } Chris@0: } else { Chris@0: $element = $namespace = null; Chris@0: } Chris@0: Chris@0: return new Node\ElementNode($namespace, $element); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses next attribute node. Chris@0: * Chris@0: * @return Node\AttributeNode Chris@0: * Chris@0: * @throws SyntaxErrorException Chris@0: */ Chris@0: private function parseAttributeNode(Node\NodeInterface $selector, TokenStream $stream) Chris@0: { Chris@0: $stream->skipWhitespace(); Chris@0: $attribute = $stream->getNextIdentifierOrStar(); Chris@0: Chris@17: if (null === $attribute && !$stream->getPeek()->isDelimiter(['|'])) { Chris@0: throw SyntaxErrorException::unexpectedToken('"|"', $stream->getPeek()); Chris@0: } Chris@0: Chris@17: if ($stream->getPeek()->isDelimiter(['|'])) { Chris@0: $stream->getNext(); Chris@0: Chris@17: if ($stream->getPeek()->isDelimiter(['='])) { Chris@0: $namespace = null; Chris@0: $stream->getNext(); Chris@0: $operator = '|='; Chris@0: } else { Chris@0: $namespace = $attribute; Chris@0: $attribute = $stream->getNextIdentifier(); Chris@0: $operator = null; Chris@0: } Chris@0: } else { Chris@0: $namespace = $operator = null; Chris@0: } Chris@0: Chris@0: if (null === $operator) { Chris@0: $stream->skipWhitespace(); Chris@0: $next = $stream->getNext(); Chris@0: Chris@17: if ($next->isDelimiter([']'])) { Chris@0: return new Node\AttributeNode($selector, $namespace, $attribute, 'exists', null); Chris@17: } elseif ($next->isDelimiter(['='])) { Chris@0: $operator = '='; Chris@17: } elseif ($next->isDelimiter(['^', '$', '*', '~', '|', '!']) Chris@17: && $stream->getPeek()->isDelimiter(['=']) Chris@0: ) { Chris@0: $operator = $next->getValue().'='; Chris@0: $stream->getNext(); Chris@0: } else { Chris@0: throw SyntaxErrorException::unexpectedToken('operator', $next); Chris@0: } Chris@0: } Chris@0: Chris@0: $stream->skipWhitespace(); Chris@0: $value = $stream->getNext(); Chris@0: Chris@0: if ($value->isNumber()) { Chris@0: // if the value is a number, it's casted into a string Chris@0: $value = new Token(Token::TYPE_STRING, (string) $value->getValue(), $value->getPosition()); Chris@0: } Chris@0: Chris@0: if (!($value->isIdentifier() || $value->isString())) { Chris@0: throw SyntaxErrorException::unexpectedToken('string or identifier', $value); Chris@0: } Chris@0: Chris@0: $stream->skipWhitespace(); Chris@0: $next = $stream->getNext(); Chris@0: Chris@17: if (!$next->isDelimiter([']'])) { Chris@0: throw SyntaxErrorException::unexpectedToken('"]"', $next); Chris@0: } Chris@0: Chris@0: return new Node\AttributeNode($selector, $namespace, $attribute, $operator, $value->getValue()); Chris@0: } Chris@0: }