From c6f87d0026817aac185c0385d7182f1dcbe0cc93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Simon?= Date: Tue, 5 Mar 2013 09:34:01 +0100 Subject: [PATCH] [CssSelector] fully rewritted component Squashed commits: [CssSelector] removed previous implementation [CssSelector] rewriting, step 1 [CssSelector] rewriting, step 2 [CssSelector] rewriting, step 3 [CssSelector] rewriting, step 4 [CssSelector] rewriting, step 5 [CssSelector] rewriting, step 6 [CssSelector] fixed shortcuts regex [CssSelector] tests, step1 [CssSelector] tests, step2 [CssSelector] tests, step3 [CssSelector] tests, step4 [CssSelector] fixed problems based @stof's on feedback [CssSelector] tests, step5 [CssSelector] tests, step6 [CssSelector] tests, step7 [CssSelector] added my name in composer.json --- .../Component/CssSelector/CssSelector.php | 301 +------------ .../Component/CssSelector/CssSelectorTest.php | 62 +++ .../Exception/ExceptionInterface.php | 24 ++ .../Exception/ExpressionErrorException.php | 24 ++ .../Exception/InternalErrorException.php | 24 ++ .../CssSelector/Exception/ParseException.php | 6 +- .../Exception/SyntaxErrorException.php | 73 ++++ .../CssSelector/Node/AbstractNode.php | 40 ++ .../Component/CssSelector/Node/AttribNode.php | 131 ------ .../CssSelector/Node/AttributeNode.php | 124 ++++++ .../Component/CssSelector/Node/ClassNode.php | 62 ++- .../CssSelector/Node/CombinedSelectorNode.php | 126 ++---- .../CssSelector/Node/ElementNode.php | 68 +-- .../CssSelector/Node/FunctionNode.php | 268 ++---------- .../Component/CssSelector/Node/HashNode.php | 60 ++- .../CssSelector/Node/NegationNode.php | 75 ++++ .../CssSelector/Node/NodeInterface.php | 27 +- .../Component/CssSelector/Node/OrNode.php | 61 --- .../Component/CssSelector/Node/PseudoNode.php | 208 ++------- .../CssSelector/Node/SelectorNode.php | 75 ++++ .../CssSelector/Node/Specificity.php | 78 ++++ .../Parser/Handler/CommentHandler.php | 47 +++ .../Parser/Handler/HandlerInterface.php | 35 ++ .../Parser/Handler/HashHandler.php | 67 +++ .../Parser/Handler/IdentifierHandler.php | 67 +++ .../Parser/Handler/NumberHandler.php | 58 +++ .../Parser/Handler/StringHandler.php | 86 ++++ .../Parser/Handler/WhitespaceHandler.php | 44 ++ .../Component/CssSelector/Parser/Parser.php | 395 ++++++++++++++++++ .../CssSelector/Parser/ParserInterface.php | 34 ++ .../Component/CssSelector/Parser/Reader.php | 126 ++++++ .../Parser/Shortcut/ClassParser.php | 42 ++ .../Parser/Shortcut/ElementParser.php | 41 ++ .../Parser/Shortcut/EmptyStringParser.php | 45 ++ .../Parser/Shortcut/HashParser.php | 42 ++ .../Component/CssSelector/Parser/Token.php | 160 +++++++ .../CssSelector/Parser/TokenStream.php | 182 ++++++++ .../Parser/Tokenizer/Tokenizer.php | 78 ++++ .../Parser/Tokenizer/TokenizerEscaping.php | 78 ++++ .../Parser/Tokenizer/TokenizerPatterns.php | 160 +++++++ .../CssSelector/Tests/CssSelectorTest.php | 71 ---- .../Tests/Node/AbstractNodeTest.php | 32 ++ .../CssSelector/Tests/Node/AttribNodeTest.php | 43 -- .../Tests/Node/AttributeNodeTest.php | 37 ++ .../CssSelector/Tests/Node/ClassNodeTest.php | 18 +- .../Tests/Node/CombinedSelectorNodeTest.php | 27 +- .../Tests/Node/ElementNodeTest.php | 25 +- .../Tests/Node/FunctionNodeTest.php | 103 ++--- .../CssSelector/Tests/Node/HashNodeTest.php | 18 +- .../Tests/Node/NegationNodeTest.php | 33 ++ .../CssSelector/Tests/Node/OrNodeTest.php | 43 -- .../CssSelector/Tests/Node/PseudoNodeTest.php | 47 +-- .../Tests/Node/SelectorNodeTest.php | 34 ++ .../Tests/Node/SpecificityTest.php | 40 ++ .../Parser/Handler/AbstractHandlerTest.php | 67 +++ .../Parser/Handler/CommentHandlerTest.php | 55 +++ .../Tests/Parser/Handler/HashHandlerTest.php | 49 +++ .../Parser/Handler/IdentifierHandlerTest.php | 49 +++ .../Parser/Handler/NumberHandlerTest.php | 51 +++ .../Parser/Handler/StringHandlerTest.php | 50 +++ .../Parser/Handler/WhitespaceHandlerTest.php | 44 ++ .../CssSelector/Tests/Parser/ParserTest.php | 247 +++++++++++ .../CssSelector/Tests/Parser/ReaderTest.php | 101 +++++ .../Tests/Parser/Shortcut/ClassParserTest.php | 40 ++ .../Parser/Shortcut/ElementParserTest.php | 42 ++ .../Tests/Parser/Shortcut/HashParserTest.php | 41 ++ .../Tests/Parser/TokenStreamTest.php | 95 +++++ .../CssSelector/Tests/TokenizerTest.php | 72 ---- .../CssSelector/Tests/XPath/Fixtures/ids.html | 48 +++ .../CssSelector/Tests/XPath/Fixtures/lang.xml | 11 + .../Tests/XPath/Fixtures/shakespear.html | 308 ++++++++++++++ .../Tests/XPath/TranslatorTest.php | 308 ++++++++++++++ .../CssSelector/Tests/XPathExprTest.php | 35 -- .../Component/CssSelector/Tests/bootstrap.php | 31 ++ src/Symfony/Component/CssSelector/Token.php | 73 ---- .../Component/CssSelector/TokenStream.php | 105 ----- .../Component/CssSelector/Tokenizer.php | 201 --------- .../XPath/Extension/AbstractExtension.php | 63 +++ .../Extension/AttributeMatchingExtension.php | 173 ++++++++ .../XPath/Extension/CombinationExtension.php | 93 +++++ .../XPath/Extension/ExtensionInterface.php | 65 +++ .../XPath/Extension/FunctionExtension.php | 198 +++++++++ .../XPath/Extension/HtmlExtension.php | 238 +++++++++++ .../XPath/Extension/NodeExtension.php | 270 ++++++++++++ .../XPath/Extension/PseudoClassExtension.php | 162 +++++++ .../CssSelector/XPath/Translator.php | 302 +++++++++++++ .../CssSelector/XPath/TranslatorInterface.php | 45 ++ .../Component/CssSelector/XPath/XPathExpr.php | 140 +++++++ .../Component/CssSelector/XPathExpr.php | 254 ----------- .../Component/CssSelector/XPathExprOr.php | 54 --- .../Component/CssSelector/composer.json | 4 + 91 files changed, 6298 insertions(+), 2161 deletions(-) create mode 100644 src/Symfony/Component/CssSelector/CssSelectorTest.php create mode 100644 src/Symfony/Component/CssSelector/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/CssSelector/Exception/ExpressionErrorException.php create mode 100644 src/Symfony/Component/CssSelector/Exception/InternalErrorException.php create mode 100644 src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php create mode 100644 src/Symfony/Component/CssSelector/Node/AbstractNode.php delete mode 100644 src/Symfony/Component/CssSelector/Node/AttribNode.php create mode 100644 src/Symfony/Component/CssSelector/Node/AttributeNode.php create mode 100644 src/Symfony/Component/CssSelector/Node/NegationNode.php delete mode 100644 src/Symfony/Component/CssSelector/Node/OrNode.php create mode 100644 src/Symfony/Component/CssSelector/Node/SelectorNode.php create mode 100644 src/Symfony/Component/CssSelector/Node/Specificity.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Handler/CommentHandler.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Handler/HandlerInterface.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Handler/HashHandler.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Handler/IdentifierHandler.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Handler/NumberHandler.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Handler/StringHandler.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Handler/WhitespaceHandler.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Parser.php create mode 100644 src/Symfony/Component/CssSelector/Parser/ParserInterface.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Reader.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Shortcut/ClassParser.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Shortcut/ElementParser.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Shortcut/EmptyStringParser.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Shortcut/HashParser.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Token.php create mode 100644 src/Symfony/Component/CssSelector/Parser/TokenStream.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Tokenizer/Tokenizer.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Tokenizer/TokenizerEscaping.php create mode 100644 src/Symfony/Component/CssSelector/Parser/Tokenizer/TokenizerPatterns.php delete mode 100644 src/Symfony/Component/CssSelector/Tests/CssSelectorTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Node/AbstractNodeTest.php delete mode 100644 src/Symfony/Component/CssSelector/Tests/Node/AttribNodeTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Node/AttributeNodeTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Node/NegationNodeTest.php delete mode 100644 src/Symfony/Component/CssSelector/Tests/Node/OrNodeTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Node/SelectorNodeTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Node/SpecificityTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Handler/AbstractHandlerTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Handler/CommentHandlerTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Handler/HashHandlerTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Handler/IdentifierHandlerTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Handler/NumberHandlerTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Handler/StringHandlerTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Handler/WhitespaceHandlerTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/ReaderTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/ClassParserTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/ElementParserTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/HashParserTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/Parser/TokenStreamTest.php delete mode 100644 src/Symfony/Component/CssSelector/Tests/TokenizerTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/ids.html create mode 100644 src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/lang.xml create mode 100644 src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/shakespear.html create mode 100644 src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php delete mode 100644 src/Symfony/Component/CssSelector/Tests/XPathExprTest.php create mode 100644 src/Symfony/Component/CssSelector/Tests/bootstrap.php delete mode 100644 src/Symfony/Component/CssSelector/Token.php delete mode 100644 src/Symfony/Component/CssSelector/TokenStream.php delete mode 100644 src/Symfony/Component/CssSelector/Tokenizer.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Extension/AttributeMatchingExtension.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Extension/CombinationExtension.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Extension/FunctionExtension.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Extension/HtmlExtension.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Extension/PseudoClassExtension.php create mode 100644 src/Symfony/Component/CssSelector/XPath/Translator.php create mode 100644 src/Symfony/Component/CssSelector/XPath/TranslatorInterface.php create mode 100644 src/Symfony/Component/CssSelector/XPath/XPathExpr.php delete mode 100644 src/Symfony/Component/CssSelector/XPathExpr.php delete mode 100644 src/Symfony/Component/CssSelector/XPathExprOr.php diff --git a/src/Symfony/Component/CssSelector/CssSelector.php b/src/Symfony/Component/CssSelector/CssSelector.php index f624fff7ee43..569fb8248da3 100644 --- a/src/Symfony/Component/CssSelector/CssSelector.php +++ b/src/Symfony/Component/CssSelector/CssSelector.php @@ -11,7 +11,13 @@ namespace Symfony\Component\CssSelector; -use Symfony\Component\CssSelector\Exception\ParseException; +use Symfony\Component\CssSelector\Exception; +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; /** * CssSelector is the main entry point of the component and can convert CSS @@ -19,8 +25,8 @@ * * $xpath = CssSelector::toXpath('h1.foo'); * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Fabien Potencier * @@ -33,290 +39,29 @@ class CssSelector * Optionally, a prefix can be added to the resulting XPath * expression with the $prefix parameter. * - * @param mixed $cssExpr The CSS expression. - * @param string $prefix An optional prefix for the XPath expression. + * @param mixed $cssExpr The CSS expression. + * @param string $prefix An optional prefix for the XPath expression. + * @param boolean $html Enables HTML extension. * * @return string * - * @throws ParseException When got None for xpath expression - * * @api */ - public static function toXPath($cssExpr, $prefix = 'descendant-or-self::') - { - if (is_string($cssExpr)) { - if (!$cssExpr) { - return $prefix.'*'; - } - - if (preg_match('#^\w+\s*$#u', $cssExpr, $match)) { - return $prefix.trim($match[0]); - } - - if (preg_match('~^(\w*)#(\w+)\s*$~u', $cssExpr, $match)) { - return sprintf("%s%s[@id = '%s']", $prefix, $match[1] ? $match[1] : '*', $match[2]); - } - - if (preg_match('#^(\w*)\.(\w+)\s*$#u', $cssExpr, $match)) { - return sprintf("%s%s[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]", $prefix, $match[1] ? $match[1] : '*', $match[2]); - } - - $parser = new self(); - $cssExpr = $parser->parse($cssExpr); - } - - $expr = $cssExpr->toXpath(); - - // @codeCoverageIgnoreStart - if (!$expr) { - throw new ParseException(sprintf('Got None for xpath expression from %s.', $cssExpr)); - } - // @codeCoverageIgnoreEnd - - if ($prefix) { - $expr->addPrefix($prefix); - } - - return (string) $expr; - } - - /** - * Parses an expression and returns the Node object that represents - * the parsed expression. - * - * @param string $string The expression to parse - * - * @return Node\NodeInterface - * - * @throws \Exception When tokenizer throws it while parsing - */ - public function parse($string) - { - $tokenizer = new Tokenizer(); - - $stream = new TokenStream($tokenizer->tokenize($string), $string); - - try { - return $this->parseSelectorGroup($stream); - } catch (\Exception $e) { - $class = get_class($e); - - throw new $class(sprintf('%s at %s -> %s', $e->getMessage(), implode($stream->getUsed(), ''), $stream->peek()), 0, $e); - } - } - - /** - * Parses a selector group contained in $stream and returns - * the Node object that represents the expression. - * - * @param TokenStream $stream The stream to parse. - * - * @return Node\NodeInterface - */ - private function parseSelectorGroup($stream) - { - $result = array(); - while (true) { - $result[] = $this->parseSelector($stream); - if ($stream->peek() == ',') { - $stream->next(); - } else { - break; - } - } - - if (count($result) == 1) { - return $result[0]; - } - - return new Node\OrNode($result); - } - - /** - * Parses a selector contained in $stream and returns the Node - * object that represents it. - * - * @param TokenStream $stream The stream containing the selector. - * - * @return Node\NodeInterface - * - * @throws ParseException When expected selector but got something else - */ - private function parseSelector($stream) - { - $result = $this->parseSimpleSelector($stream); - - while (true) { - $peek = $stream->peek(); - if (',' == $peek || null === $peek) { - return $result; - } elseif (in_array($peek, array('+', '>', '~'))) { - // A combinator - $combinator = (string) $stream->next(); - - // Ignore optional whitespace after a combinator - while (' ' == $stream->peek()) { - $stream->next(); - } - } else { - $combinator = ' '; - } - $consumed = count($stream->getUsed()); - $nextSelector = $this->parseSimpleSelector($stream); - if ($consumed == count($stream->getUsed())) { - throw new ParseException(sprintf("Expected selector, got '%s'", $stream->peek())); - } - - $result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector); - } - - return $result; - } - - /** - * Parses a simple selector (the current token) from $stream and returns - * the resulting Node object. - * - * @param TokenStream $stream The stream containing the selector. - * - * @return Node\NodeInterface - * - * @throws ParseException When expected symbol but got something else - */ - private function parseSimpleSelector($stream) - { - $peek = $stream->peek(); - if ('*' != $peek && !$peek->isType('Symbol')) { - $element = $namespace = '*'; - } else { - $next = $stream->next(); - if ('*' != $next && !$next->isType('Symbol')) { - throw new ParseException(sprintf("Expected symbol, got '%s'", $next)); - } - - if ($stream->peek() == '|') { - $namespace = $next; - $stream->next(); - $element = $stream->next(); - if ('*' != $element && !$next->isType('Symbol')) { - throw new ParseException(sprintf("Expected symbol, got '%s'", $next)); - } - } else { - $namespace = '*'; - $element = $next; - } - } - - $result = new Node\ElementNode($namespace, $element); - $hasHash = false; - while (true) { - $peek = $stream->peek(); - if ('#' == $peek) { - if ($hasHash) { - /* You can't have two hashes - (FIXME: is there some more general rule I'm missing?) */ - // @codeCoverageIgnoreStart - break; - // @codeCoverageIgnoreEnd - } - $stream->next(); - $result = new Node\HashNode($result, $stream->next()); - $hasHash = true; - - continue; - } elseif ('.' == $peek) { - $stream->next(); - $result = new Node\ClassNode($result, $stream->next()); - - continue; - } elseif ('[' == $peek) { - $stream->next(); - $result = $this->parseAttrib($result, $stream); - $next = $stream->next(); - if (']' != $next) { - throw new ParseException(sprintf("] expected, got '%s'", $next)); - } - - continue; - } elseif (':' == $peek || '::' == $peek) { - $type = $stream->next(); - $ident = $stream->next(); - if (!$ident || !$ident->isType('Symbol')) { - throw new ParseException(sprintf("Expected symbol, got '%s'", $ident)); - } - - if ($stream->peek() == '(') { - $stream->next(); - $peek = $stream->peek(); - if ($peek->isType('String')) { - $selector = $stream->next(); - } elseif ($peek->isType('Symbol') && is_int($peek)) { - $selector = intval($stream->next()); - } else { - // FIXME: parseSimpleSelector, or selector, or...? - $selector = $this->parseSimpleSelector($stream); - } - $next = $stream->next(); - if (')' != $next) { - throw new ParseException(sprintf("Expected ')', got '%s' and '%s'", $next, $selector)); - } - - $result = new Node\FunctionNode($result, $type, $ident, $selector); - } else { - $result = new Node\PseudoNode($result, $type, $ident); - } - - continue; - } else { - if (' ' == $peek) { - $stream->next(); - } - - break; - } - // FIXME: not sure what "negation" is - } - - return $result; - } - - /** - * Parses an attribute from a selector contained in $stream and returns - * the resulting AttribNode object. - * - * @param Node\NodeInterface $selector The selector object whose attribute - * is to be parsed. - * @param TokenStream $stream The container token stream. - * - * @return Node\AttribNode - * - * @throws ParseException When encountered unexpected selector - */ - private function parseAttrib($selector, $stream) + public static function toXPath($cssExpr, $prefix = 'descendant-or-self::', $html = true) { - $attrib = $stream->next(); - if ($stream->peek() == '|') { - $namespace = $attrib; - $stream->next(); - $attrib = $stream->next(); - } else { - $namespace = '*'; - } + $translator = new Translator(); - if ($stream->peek() == ']') { - return new Node\AttribNode($selector, $namespace, $attrib, 'exists', null); + if ($html) { + $translator->registerExtension(new HtmlExtension($translator)); } - $op = $stream->next(); - if (!in_array($op, array('^=', '$=', '*=', '=', '~=', '|=', '!='))) { - throw new ParseException(sprintf("Operator expected, got '%s'", $op)); - } - - $value = $stream->next(); - if (!$value->isType('Symbol') && !$value->isType('String')) { - throw new ParseException(sprintf("Expected string or symbol, got '%s'", $value)); - } + $translator + ->registerParserShortcut(new EmptyStringParser()) + ->registerParserShortcut(new ElementParser()) + ->registerParserShortcut(new ClassParser()) + ->registerParserShortcut(new HashParser()) + ; - return new Node\AttribNode($selector, $namespace, $attrib, $op, $value); + return $translator->cssToXPath($cssExpr, $prefix); } } diff --git a/src/Symfony/Component/CssSelector/CssSelectorTest.php b/src/Symfony/Component/CssSelector/CssSelectorTest.php new file mode 100644 index 000000000000..e2ca42ff4c2b --- /dev/null +++ b/src/Symfony/Component/CssSelector/CssSelectorTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector; + +class CssSelectorTest extends \PHPUnit_Framework_TestCase +{ + public function testCssToXPath() + { + $this->assertEquals('descendant-or-self::*', CssSelector::toXPath('')); + $this->assertEquals('descendant-or-self::h1', CssSelector::toXPath('h1')); + $this->assertEquals("descendant-or-self::h1[@id = 'foo']", CssSelector::toXPath('h1#foo')); + $this->assertEquals("descendant-or-self::h1[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]", CssSelector::toXPath('h1.foo')); + $this->assertEquals('descendant-or-self::foo:h1', CssSelector::toXPath('foo|h1')); + } + + /** @dataProvider getCssToXPathWithoutPrefixTestData */ + public function testCssToXPathWithoutPrefix($css, $xpath) + { + $this->assertEquals($xpath, CssSelector::toXPath($css, ''), '->parse() parses an input string and returns a node'); + } + + public function testParseExceptions() + { + try { + CssSelector::toXPath('h1:'); + $this->fail('->parse() throws an Exception if the css selector is not valid'); + } catch (\Exception $e) { + $this->assertInstanceOf('\Symfony\Component\CssSelector\Exception\ParseException', $e, '->parse() throws an Exception if the css selector is not valid'); + $this->assertEquals("Expected identifier, but found.", $e->getMessage(), '->parse() throws an Exception if the css selector is not valid'); + } + } + + public function getCssToXPathWithoutPrefixTestData() + { + return array( + array('h1', "h1"), + array('foo|h1', "foo:h1"), + array('h1, h2, h3', "h1 | h2 | h3"), + array('h1:nth-child(3n+1)', "*/*[name() = 'h1' and ((position() -1) mod 3 = 0 and position() >= 1)]"), + array('h1 > p', "h1/p"), + array('h1#foo', "h1[@id = 'foo']"), + array('h1.foo', "h1[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"), + array('h1[class*="foo bar"]', "h1[@class and contains(@class, 'foo bar')]"), + array('h1[foo|class*="foo bar"]', "h1[@foo:class and contains(@foo:class, 'foo bar')]"), + array('h1[class]', "h1[@class]"), + array('h1 .foo', "h1/descendant-or-self::*/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"), + array('h1 #foo', "h1/descendant-or-self::*/*[@id = 'foo']"), + array('h1 [class*=foo]', "h1/descendant-or-self::*/*[@class and contains(@class, 'foo')]"), + array('div>.foo', "div/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"), + array('div > .foo', "div/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Exception/ExceptionInterface.php b/src/Symfony/Component/CssSelector/Exception/ExceptionInterface.php new file mode 100644 index 000000000000..da01c2b27177 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Exception/ExceptionInterface.php @@ -0,0 +1,24 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +interface ExceptionInterface +{ +} diff --git a/src/Symfony/Component/CssSelector/Exception/ExpressionErrorException.php b/src/Symfony/Component/CssSelector/Exception/ExpressionErrorException.php new file mode 100644 index 000000000000..151dbf035046 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Exception/ExpressionErrorException.php @@ -0,0 +1,24 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class ExpressionErrorException extends ParseException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/CssSelector/Exception/InternalErrorException.php b/src/Symfony/Component/CssSelector/Exception/InternalErrorException.php new file mode 100644 index 000000000000..8a815fb9ea5f --- /dev/null +++ b/src/Symfony/Component/CssSelector/Exception/InternalErrorException.php @@ -0,0 +1,24 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class InternalErrorException extends ParseException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/CssSelector/Exception/ParseException.php b/src/Symfony/Component/CssSelector/Exception/ParseException.php index 38206c241163..9c119f84c6a3 100644 --- a/src/Symfony/Component/CssSelector/Exception/ParseException.php +++ b/src/Symfony/Component/CssSelector/Exception/ParseException.php @@ -14,11 +14,11 @@ /** * ParseException is thrown when a CSS selector syntax is not valid. * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * * @author Fabien Potencier */ -class ParseException extends \Exception +class ParseException extends \Exception implements ExceptionInterface { } diff --git a/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php b/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php new file mode 100644 index 000000000000..529b891a3f5a --- /dev/null +++ b/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php @@ -0,0 +1,73 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class SyntaxErrorException extends ParseException implements ExceptionInterface +{ + /** + * @param string $expectedValue + * @param Token $foundToken + * + * @return SyntaxErrorException + */ + public static function unexpectedToken($expectedValue, Token $foundToken) + { + return new self(sprintf('Expected %s, but %s found.', $expectedValue, $foundToken)); + } + + /** + * @param string $pseudoElement + * @param string $unexpectedLocation + * + * @return SyntaxErrorException + */ + public static function pseudoElementFound($pseudoElement, $unexpectedLocation) + { + return new self(sprintf('Unexpected pseudo-element "::%s" found %s.', $pseudoElement, $unexpectedLocation)); + } + + /** + * @param int $position + * + * @return SyntaxErrorException + */ + public static function unclosedString($position) + { + return new self(sprintf('Unclosed/invalid string at %s.', $position)); + } + + /** + * @return SyntaxErrorException + */ + public static function nestedNot() + { + return new self('Got nested ::not().'); + } + + /** + * @return SyntaxErrorException + */ + public static function stringAsFunctionArgument() + { + return new self('String not allowed as function argument.'); + } +} diff --git a/src/Symfony/Component/CssSelector/Node/AbstractNode.php b/src/Symfony/Component/CssSelector/Node/AbstractNode.php new file mode 100644 index 000000000000..f5324e191b0d --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/AbstractNode.php @@ -0,0 +1,40 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +abstract class AbstractNode implements NodeInterface +{ + /** + * @var string + */ + private $nodeName; + + /** + * @return string + */ + public function getNodeName() + { + if (null === $this->nodeName) { + $this->nodeName = preg_replace('~.*\\\\([^\\\\]+)Node$~', '$1', get_called_class()); + } + + return $this->nodeName; + } +} diff --git a/src/Symfony/Component/CssSelector/Node/AttribNode.php b/src/Symfony/Component/CssSelector/Node/AttribNode.php deleted file mode 100644 index b89606e5426e..000000000000 --- a/src/Symfony/Component/CssSelector/Node/AttribNode.php +++ /dev/null @@ -1,131 +0,0 @@ - - * - * 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\XPathExpr; -use Symfony\Component\CssSelector\Exception\ParseException; - -/** - * AttribNode represents a "selector[namespace|attrib operator value]" node. - * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. - * - * @author Fabien Potencier - */ -class AttribNode implements NodeInterface -{ - protected $selector; - protected $namespace; - protected $attrib; - protected $operator; - protected $value; - - /** - * Constructor. - * - * @param NodeInterface $selector The XPath selector - * @param string $namespace The namespace - * @param string $attrib The attribute - * @param string $operator The operator - * @param string $value The value - */ - public function __construct($selector, $namespace, $attrib, $operator, $value) - { - $this->selector = $selector; - $this->namespace = $namespace; - $this->attrib = $attrib; - $this->operator = $operator; - $this->value = $value; - } - - /** - * {@inheritDoc} - */ - public function __toString() - { - if ($this->operator == 'exists') { - return sprintf('%s[%s[%s]]', __CLASS__, $this->selector, $this->formatAttrib()); - } - - return sprintf('%s[%s[%s %s %s]]', __CLASS__, $this->selector, $this->formatAttrib(), $this->operator, $this->value); - } - - /** - * {@inheritDoc} - */ - public function toXpath() - { - $path = $this->selector->toXpath(); - $attrib = $this->xpathAttrib(); - $value = $this->value; - if ($this->operator == 'exists') { - $path->addCondition($attrib); - } elseif ($this->operator == '=') { - $path->addCondition(sprintf('%s = %s', $attrib, XPathExpr::xpathLiteral($value))); - } elseif ($this->operator == '!=') { - // FIXME: this seems like a weird hack... - if ($value) { - $path->addCondition(sprintf('not(%s) or %s != %s', $attrib, $attrib, XPathExpr::xpathLiteral($value))); - } else { - $path->addCondition(sprintf('%s != %s', $attrib, XPathExpr::xpathLiteral($value))); - } - // path.addCondition('%s != %s' % (attrib, xpathLiteral(value))) - } elseif ($this->operator == '~=') { - $path->addCondition(sprintf("contains(concat(' ', normalize-space(%s), ' '), %s)", $attrib, XPathExpr::xpathLiteral(' '.$value.' '))); - } elseif ($this->operator == '|=') { - // Weird, but true... - $path->addCondition(sprintf('%s = %s or starts-with(%s, %s)', $attrib, XPathExpr::xpathLiteral($value), $attrib, XPathExpr::xpathLiteral($value.'-'))); - } elseif ($this->operator == '^=') { - $path->addCondition(sprintf('starts-with(%s, %s)', $attrib, XPathExpr::xpathLiteral($value))); - } elseif ($this->operator == '$=') { - // Oddly there is a starts-with in XPath 1.0, but not ends-with - $path->addCondition(sprintf('substring(%s, string-length(%s)-%s) = %s', $attrib, $attrib, strlen($value) - 1, XPathExpr::xpathLiteral($value))); - } elseif ($this->operator == '*=') { - // FIXME: case sensitive? - $path->addCondition(sprintf('contains(%s, %s)', $attrib, XPathExpr::xpathLiteral($value))); - } else { - throw new ParseException(sprintf('Unknown operator: %s', $this->operator)); - } - - return $path; - } - - /** - * Returns the XPath Attribute - * - * @return string The XPath attribute - */ - protected function xpathAttrib() - { - // FIXME: if attrib is *? - if ($this->namespace == '*') { - return '@'.$this->attrib; - } - - return sprintf('@%s:%s', $this->namespace, $this->attrib); - } - - /** - * Returns a formatted attribute - * - * @return string The formatted attribute - */ - protected function formatAttrib() - { - if ($this->namespace == '*') { - return $this->attrib; - } - - return sprintf('%s|%s', $this->namespace, $this->attrib); - } -} diff --git a/src/Symfony/Component/CssSelector/Node/AttributeNode.php b/src/Symfony/Component/CssSelector/Node/AttributeNode.php new file mode 100644 index 000000000000..e2fa9a294c51 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/AttributeNode.php @@ -0,0 +1,124 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class AttributeNode extends AbstractNode +{ + /** + * @var NodeInterface + */ + private $selector; + + /** + * @var string + */ + private $namespace; + + /** + * @var string + */ + private $attribute; + + /** + * @var string + */ + private $operator; + + /** + * @var string + */ + private $value; + + /** + * @param NodeInterface $selector + * @param string $namespace + * @param string $attribute + * @param string $operator + * @param string $value + */ + public function __construct(NodeInterface $selector, $namespace, $attribute, $operator, $value) + { + $this->selector = $selector; + $this->namespace = $namespace; + $this->attribute = $attribute; + $this->operator = $operator; + $this->value = $value; + } + + /** + * @return NodeInterface + */ + public function getSelector() + { + return $this->selector; + } + + /** + * @return string + */ + public function getNamespace() + { + return $this->namespace; + } + + /** + * @return string + */ + public function getAttribute() + { + return $this->attribute; + } + + /** + * @return string + */ + public function getOperator() + { + return $this->operator; + } + + /** + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity() + { + return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + $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); + } +} diff --git a/src/Symfony/Component/CssSelector/Node/ClassNode.php b/src/Symfony/Component/CssSelector/Node/ClassNode.php index 014aa80e9e99..a7a59a33a9f4 100644 --- a/src/Symfony/Component/CssSelector/Node/ClassNode.php +++ b/src/Symfony/Component/CssSelector/Node/ClassNode.php @@ -11,49 +11,65 @@ namespace Symfony\Component\CssSelector\Node; -use Symfony\Component\CssSelector\XPathExpr; - /** - * ClassNode represents a "selector.className" node. + * Represents a "." node. * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * - * @author Fabien Potencier + * @author Jean-François Simon */ -class ClassNode implements NodeInterface +class ClassNode extends AbstractNode { - protected $selector; - protected $className; + /** + * @var NodeInterface + */ + private $selector; /** - * The constructor. - * - * @param NodeInterface $selector The XPath Selector - * @param string $className The class name + * @var string */ - public function __construct($selector, $className) + private $name; + + /** + * @param NodeInterface $selector + * @param string $name + */ + public function __construct(NodeInterface $selector, $name) { $this->selector = $selector; - $this->className = $className; + $this->name = $name; } /** - * {@inheritDoc} + * @return NodeInterface */ - public function __toString() + public function getSelector() { - return sprintf('%s[%s.%s]', __CLASS__, $this->selector, $this->className); + return $this->selector; } /** - * {@inheritDoc} + * @return string */ - public function toXpath() + public function getName() { - $selXpath = $this->selector->toXpath(); - $selXpath->addCondition(sprintf("contains(concat(' ', normalize-space(@class), ' '), %s)", XPathExpr::xpathLiteral(' '.$this->className.' '))); + return $this->name; + } - return $selXpath; + /** + * {@inheritdoc} + */ + public function getSpecificity() + { + return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return sprintf('%s[%s.%s]', $this->getNodeName(), $this->selector, $this->name); } } diff --git a/src/Symfony/Component/CssSelector/Node/CombinedSelectorNode.php b/src/Symfony/Component/CssSelector/Node/CombinedSelectorNode.php index 13e39270e0c9..4e085ea4e1a7 100644 --- a/src/Symfony/Component/CssSelector/Node/CombinedSelectorNode.php +++ b/src/Symfony/Component/CssSelector/Node/CombinedSelectorNode.php @@ -11,132 +11,82 @@ namespace Symfony\Component\CssSelector\Node; -use Symfony\Component\CssSelector\Exception\ParseException; - /** - * CombinedSelectorNode represents a combinator node. + * Represents a combined node. * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * - * @author Fabien Potencier + * @author Jean-François Simon */ -class CombinedSelectorNode implements NodeInterface +class CombinedSelectorNode extends AbstractNode { - protected static $methodMapping = array( - ' ' => 'descendant', - '>' => 'child', - '+' => 'direct_adjacent', - '~' => 'indirect_adjacent', - ); + /** + * @var NodeInterface + */ + private $selector; - protected $selector; - protected $combinator; - protected $subselector; + /** + * @var string + */ + private $combinator; /** - * The constructor. - * - * @param NodeInterface $selector The XPath selector - * @param string $combinator The combinator - * @param NodeInterface $subselector The sub XPath selector + * @var NodeInterface */ - public function __construct($selector, $combinator, $subselector) - { - $this->selector = $selector; - $this->combinator = $combinator; - $this->subselector = $subselector; - } + private $subSelector; /** - * {@inheritDoc} + * @param NodeInterface $selector + * @param string $combinator + * @param NodeInterface $subSelector */ - public function __toString() + public function __construct(NodeInterface $selector, $combinator, NodeInterface $subSelector) { - $comb = $this->combinator == ' ' ? '' : $this->combinator; - - return sprintf('%s[%s %s %s]', __CLASS__, $this->selector, $comb, $this->subselector); + $this->selector = $selector; + $this->combinator = $combinator; + $this->subSelector = $subSelector; } /** - * {@inheritDoc} - * @throws ParseException When unknown combinator is found + * @return NodeInterface */ - public function toXpath() + public function getSelector() { - if (!isset(self::$methodMapping[$this->combinator])) { - throw new ParseException(sprintf('Unknown combinator: %s', $this->combinator)); - } - - $method = '_xpath_'.self::$methodMapping[$this->combinator]; - $path = $this->selector->toXpath(); - - return $this->$method($path, $this->subselector); + return $this->selector; } /** - * Joins a NodeInterface into the XPath of this object. - * - * @param XPathExpr $xpath The XPath expression for this object - * @param NodeInterface $sub The NodeInterface object to add - * - * @return XPathExpr An XPath instance + * @return string */ - protected function _xpath_descendant($xpath, $sub) + public function getCombinator() { - // when sub is a descendant in any way of xpath - $xpath->join('/descendant::', $sub->toXpath()); - - return $xpath; + return $this->combinator; } /** - * Joins a NodeInterface as a child of this object. - * - * @param XPathExpr $xpath The parent XPath expression - * @param NodeInterface $sub The NodeInterface object to add - * - * @return XPathExpr An XPath instance + * @return NodeInterface */ - protected function _xpath_child($xpath, $sub) + public function getSubSelector() { - // when sub is an immediate child of xpath - $xpath->join('/', $sub->toXpath()); - - return $xpath; + return $this->subSelector; } /** - * Joins an XPath expression as an adjacent of another. - * - * @param XPathExpr $xpath The parent XPath expression - * @param NodeInterface $sub The adjacent XPath expression - * - * @return XPathExpr An XPath instance + * {@inheritdoc} */ - protected function _xpath_direct_adjacent($xpath, $sub) + public function getSpecificity() { - // when sub immediately follows xpath - $xpath->join('/following-sibling::', $sub->toXpath()); - $xpath->addNameTest(); - $xpath->addCondition('position() = 1'); - - return $xpath; + return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); } /** - * Joins an XPath expression as an indirect adjacent of another. - * - * @param XPathExpr $xpath The parent XPath expression - * @param NodeInterface $sub The indirect adjacent NodeInterface object - * - * @return XPathExpr An XPath instance + * {@inheritdoc} */ - protected function _xpath_indirect_adjacent($xpath, $sub) + public function __toString() { - // when sub comes somewhere after xpath as a sibling - $xpath->join('/following-sibling::', $sub->toXpath()); + $combinator = ' ' === $this->combinator ? '' : $this->combinator; - return $xpath; + return sprintf('%s[%s %s %s]', $this->getNodeName(), $this->selector, $combinator, $this->subSelector); } } diff --git a/src/Symfony/Component/CssSelector/Node/ElementNode.php b/src/Symfony/Component/CssSelector/Node/ElementNode.php index 5cc7d4b5d5a6..9ab13c3f2437 100644 --- a/src/Symfony/Component/CssSelector/Node/ElementNode.php +++ b/src/Symfony/Component/CssSelector/Node/ElementNode.php @@ -11,67 +11,67 @@ namespace Symfony\Component\CssSelector\Node; -use Symfony\Component\CssSelector\XPathExpr; - /** - * ElementNode represents a "namespace|element" node. + * Represents a "|" node. * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * - * @author Fabien Potencier + * @author Jean-François Simon */ -class ElementNode implements NodeInterface +class ElementNode extends AbstractNode { - protected $namespace; - protected $element; + /** + * @var string|null + */ + private $namespace; + + /** + * @var string|null + */ + private $element; /** - * Constructor. - * - * @param string $namespace Namespace - * @param string $element Element + * @param string|null $namespace + * @param string|null $element */ - public function __construct($namespace, $element) + public function __construct($namespace = null, $element = null) { $this->namespace = $namespace; $this->element = $element; } /** - * {@inheritDoc} + * @return null|string */ - public function __toString() + public function getNamespace() { - return sprintf('%s[%s]', __CLASS__, $this->formatElement()); + return $this->namespace; } /** - * Formats the element into a string. - * - * @return string Element as an XPath string + * @return null|string */ - public function formatElement() + public function getElement() { - if ($this->namespace == '*') { - return $this->element; - } + return $this->element; + } - return sprintf('%s|%s', $this->namespace, $this->element); + /** + * {@inheritdoc} + */ + public function getSpecificity() + { + return new Specificity(0, 0, $this->element ? 1 : 0); } /** - * {@inheritDoc} + * {@inheritdoc} */ - public function toXpath() + public function __toString() { - if ($this->namespace == '*') { - $el = strtolower($this->element); - } else { - // FIXME: Should we lowercase here? - $el = sprintf('%s:%s', $this->namespace, $this->element); - } + $element = $this->element ?: '*'; - return new XPathExpr(null, null, $el); + return sprintf('%s[%s]', $this->getNodeName(), $this->namespace ? $this->namespace.'|'.$element : $element); } } diff --git a/src/Symfony/Component/CssSelector/Node/FunctionNode.php b/src/Symfony/Component/CssSelector/Node/FunctionNode.php index 8736c27c05fc..ecd11a50b00d 100644 --- a/src/Symfony/Component/CssSelector/Node/FunctionNode.php +++ b/src/Symfony/Component/CssSelector/Node/FunctionNode.php @@ -11,280 +11,86 @@ namespace Symfony\Component\CssSelector\Node; -use Symfony\Component\CssSelector\Exception\ParseException; -use Symfony\Component\CssSelector\XPathExpr; +use Symfony\Component\CssSelector\Parser\Token; /** - * FunctionNode represents a "selector:name(expr)" node. + * Represents a ":()" node. * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * - * @author Fabien Potencier + * @author Jean-François Simon */ -class FunctionNode implements NodeInterface +class FunctionNode extends AbstractNode { - protected static $unsupported = array('target', 'lang', 'enabled', 'disabled'); - - protected $selector; - protected $type; - protected $name; - protected $expr; - /** - * Constructor. - * - * @param NodeInterface $selector The XPath expression - * @param string $type - * @param string $name - * @param XPathExpr $expr + * @var NodeInterface */ - public function __construct($selector, $type, $name, $expr) - { - $this->selector = $selector; - $this->type = $type; - $this->name = $name; - $this->expr = $expr; - } + private $selector; /** - * {@inheritDoc} + * @var string */ - public function __toString() - { - return sprintf('%s[%s%s%s(%s)]', __CLASS__, $this->selector, $this->type, $this->name, $this->expr); - } + private $name; /** - * {@inheritDoc} - * @throws ParseException When unsupported or unknown pseudo-class is found + * @var Token[] */ - public function toXpath() - { - $selPath = $this->selector->toXpath(); - if (in_array($this->name, self::$unsupported)) { - throw new ParseException(sprintf('The pseudo-class %s is not supported', $this->name)); - } - $method = '_xpath_'.str_replace('-', '_', $this->name); - if (!method_exists($this, $method)) { - throw new ParseException(sprintf('The pseudo-class %s is unknown', $this->name)); - } - - return $this->$method($selPath, $this->expr); - } + private $arguments; /** - * undocumented function - * - * @param XPathExpr $xpath - * @param mixed $expr - * @param Boolean $last - * @param Boolean $addNameTest - * - * @return XPathExpr - */ - protected function _xpath_nth_child($xpath, $expr, $last = false, $addNameTest = true) - { - list($a, $b) = $this->parseSeries($expr); - if (!$a && !$b && !$last) { - // a=0 means nothing is returned... - $xpath->addCondition('false() and position() = 0'); - - return $xpath; - } - - if ($addNameTest) { - $xpath->addNameTest(); - } - - $xpath->addStarPrefix(); - if ($a == 0) { - if ($last) { - $b = sprintf('last() - %s', $b); - } - $xpath->addCondition(sprintf('position() = %s', $b)); - - return $xpath; - } - - if ($last) { - // FIXME: I'm not sure if this is right - $a = -$a; - $b = -$b; - } - - if ($b > 0) { - $bNeg = -$b; - } else { - $bNeg = sprintf('+%s', -$b); - } - - if ($a != 1) { - $expr = array(sprintf('(position() %s) mod %s = 0', $bNeg, $a)); - } else { - $expr = array(); - } - - if ($b >= 0) { - $expr[] = sprintf('position() >= %s', $b); - } elseif ($b < 0 && $last) { - $expr[] = sprintf('position() < (last() %s)', $b); - } - $expr = implode($expr, ' and '); - - if ($expr) { - $xpath->addCondition($expr); - } - - return $xpath; - /* FIXME: 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 */ - } - - /** - * undocumented function - * - * @param XPathExpr $xpath - * @param XPathExpr $expr - * - * @return XPathExpr + * @param NodeInterface $selector + * @param string $name + * @param Token[] $arguments */ - protected function _xpath_nth_last_child($xpath, $expr) + public function __construct(NodeInterface $selector, $name, array $arguments = array()) { - return $this->_xpath_nth_child($xpath, $expr, true); + $this->selector = $selector; + $this->name = strtolower($name); + $this->arguments = $arguments; } /** - * undocumented function - * - * @param XPathExpr $xpath - * @param XPathExpr $expr - * - * @return XPathExpr - * - * @throws ParseException + * @return NodeInterface */ - protected function _xpath_nth_of_type($xpath, $expr) + public function getSelector() { - if ($xpath->getElement() == '*') { - throw new ParseException('*:nth-of-type() is not implemented'); - } - - return $this->_xpath_nth_child($xpath, $expr, false, false); + return $this->selector; } /** - * undocumented function - * - * @param XPathExpr $xpath - * @param XPathExpr $expr - * - * @return XPathExpr + * @return string */ - protected function _xpath_nth_last_of_type($xpath, $expr) + public function getName() { - return $this->_xpath_nth_child($xpath, $expr, true, false); + return $this->name; } /** - * undocumented function - * - * @param XPathExpr $xpath - * @param XPathExpr $expr - * - * @return XPathExpr + * @return Token[] */ - protected function _xpath_contains($xpath, $expr) + public function getArguments() { - // text content, minus tags, must contain expr - if ($expr instanceof ElementNode) { - $expr = $expr->formatElement(); - } - - // FIXME: lower-case is only available with XPath 2 - //$xpath->addCondition(sprintf('contains(lower-case(string(.)), %s)', XPathExpr::xpathLiteral(strtolower($expr)))); - $xpath->addCondition(sprintf('contains(string(.), %s)', XPathExpr::xpathLiteral($expr))); - - // FIXME: Currently case insensitive matching doesn't seem to be happening - return $xpath; + return $this->arguments; } /** - * undocumented function - * - * @param XPathExpr $xpath - * @param XPathExpr $expr - * - * @return XPathExpr + * {@inheritdoc} */ - protected function _xpath_not($xpath, $expr) + public function getSpecificity() { - // everything for which not expr applies - $expr = $expr->toXpath(); - $cond = $expr->getCondition(); - // FIXME: should I do something about element_path? - $xpath->addCondition(sprintf('not(%s)', $cond)); - - return $xpath; + return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); } /** - * Parses things like '1n+2', or 'an+b' generally, returning (a, b) - * - * @param mixed $s - * - * @return array + * {@inheritdoc} */ - protected function parseSeries($s) + public function __toString() { - if ($s instanceof ElementNode) { - $s = $s->formatElement(); - } - - if (!$s || '*' == $s) { - // Happens when there's nothing, which the CSS parser thinks of as * - return array(0, 0); - } - - if ('odd' == $s) { - return array(2, 1); - } - - if ('even' == $s) { - return array(2, 0); - } - - if ('n' == $s) { - return array(1, 0); - } - - if (false === strpos($s, 'n')) { - // Just a b - return array(0, intval((string) $s)); - } - - list($a, $b) = explode('n', $s); - if (!$a) { - $a = 1; - } elseif ('-' == $a || '+' == $a) { - $a = intval($a.'1'); - } else { - $a = intval($a); - } - - if (!$b) { - $b = 0; - } elseif ('-' == $b || '+' == $b) { - $b = intval($b.'1'); - } else { - $b = intval($b); - } + $arguments = implode(', ', array_map(function (Token $token) { + return "'".$token->getValue()."'"; + }, $this->arguments)); - return array($a, $b); + return sprintf('%s[%s:%s(%s)]', $this->getNodeName(), $this->selector, $this->name, $arguments ? '['.$arguments.']' : ''); } } diff --git a/src/Symfony/Component/CssSelector/Node/HashNode.php b/src/Symfony/Component/CssSelector/Node/HashNode.php index 87a6590affe6..7fb407579b61 100644 --- a/src/Symfony/Component/CssSelector/Node/HashNode.php +++ b/src/Symfony/Component/CssSelector/Node/HashNode.php @@ -11,49 +11,65 @@ namespace Symfony\Component\CssSelector\Node; -use Symfony\Component\CssSelector\XPathExpr; - /** - * HashNode represents a "selector#id" node. + * Represents a "#" node. * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * - * @author Fabien Potencier + * @author Jean-François Simon */ -class HashNode implements NodeInterface +class HashNode extends AbstractNode { - protected $selector; - protected $id; + /** + * @var NodeInterface + */ + private $selector; /** - * Constructor. - * - * @param NodeInterface $selector The NodeInterface object - * @param string $id The ID + * @var string */ - public function __construct($selector, $id) + private $id; + + /** + * @param NodeInterface $selector + * @param string $id + */ + public function __construct(NodeInterface $selector, $id) { $this->selector = $selector; $this->id = $id; } /** - * {@inheritDoc} + * @return NodeInterface */ - public function __toString() + public function getSelector() { - return sprintf('%s[%s#%s]', __CLASS__, $this->selector, $this->id); + return $this->selector; } /** - * {@inheritDoc} + * @return string */ - public function toXpath() + public function getId() { - $path = $this->selector->toXpath(); - $path->addCondition(sprintf('@id = %s', XPathExpr::xpathLiteral($this->id))); + return $this->id; + } - return $path; + /** + * {@inheritdoc} + */ + public function getSpecificity() + { + return $this->selector->getSpecificity()->plus(new Specificity(1, 0, 0)); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return sprintf('%s[%s#%s]', $this->getNodeName(), $this->selector, $this->id); } } diff --git a/src/Symfony/Component/CssSelector/Node/NegationNode.php b/src/Symfony/Component/CssSelector/Node/NegationNode.php new file mode 100644 index 000000000000..2529689095e5 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/NegationNode.php @@ -0,0 +1,75 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class NegationNode extends AbstractNode +{ + /** + * @var NodeInterface + */ + private $selector; + + /** + * @var NodeInterface + */ + private $subSelector; + + /** + * @param NodeInterface $selector + * @param NodeInterface $subSelector + */ + public function __construct(NodeInterface $selector, NodeInterface $subSelector) + { + $this->selector = $selector; + $this->subSelector = $subSelector; + } + + /** + * @return NodeInterface + */ + public function getSelector() + { + return $this->selector; + } + + /** + * @return NodeInterface + */ + public function getSubSelector() + { + return $this->subSelector; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity() + { + return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return sprintf('%s[%s:not(%s)]', $this->getNodeName(), $this->selector, $this->subSelector); + } +} diff --git a/src/Symfony/Component/CssSelector/Node/NodeInterface.php b/src/Symfony/Component/CssSelector/Node/NodeInterface.php index 113b1b75d7c5..1601c33a6ed3 100644 --- a/src/Symfony/Component/CssSelector/Node/NodeInterface.php +++ b/src/Symfony/Component/CssSelector/Node/NodeInterface.php @@ -12,26 +12,33 @@ namespace Symfony\Component\CssSelector\Node; /** - * ClassNode represents a "selector.className" node. + * Interface for nodes. * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * - * @author Fabien Potencier + * @author Jean-François Simon */ interface NodeInterface { /** - * Returns a string representation of the object. + * Returns node's name. * - * @return string The string representation + * @return string */ - public function __toString(); + public function getNodeName(); + + /** + * Returns node's specificity. + * + * @return Specificity + */ + public function getSpecificity(); /** - * @return XPathExpr The XPath expression + * Returns node's string representation. * - * @throws ParseException When unknown operator is found + * @return string */ - public function toXpath(); + public function __toString(); } diff --git a/src/Symfony/Component/CssSelector/Node/OrNode.php b/src/Symfony/Component/CssSelector/Node/OrNode.php deleted file mode 100644 index 374a577d2cc9..000000000000 --- a/src/Symfony/Component/CssSelector/Node/OrNode.php +++ /dev/null @@ -1,61 +0,0 @@ - - * - * 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\XPathExprOr; - -/** - * OrNode represents a "Or" node. - * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. - * - * @author Fabien Potencier - */ -class OrNode implements NodeInterface -{ - /** - * @var NodeInterface[] - */ - protected $items; - - /** - * Constructor. - * - * @param NodeInterface[] $items An array of NodeInterface objects - */ - public function __construct($items) - { - $this->items = $items; - } - - /** - * {@inheritDoc} - */ - public function __toString() - { - return sprintf('%s(%s)', __CLASS__, $this->items); - } - - /** - * {@inheritDoc} - */ - public function toXpath() - { - $paths = array(); - foreach ($this->items as $item) { - $paths[] = $item->toXpath(); - } - - return new XPathExprOr($paths); - } -} diff --git a/src/Symfony/Component/CssSelector/Node/PseudoNode.php b/src/Symfony/Component/CssSelector/Node/PseudoNode.php index 2aa4c65f825a..4f2d538f6f52 100644 --- a/src/Symfony/Component/CssSelector/Node/PseudoNode.php +++ b/src/Symfony/Component/CssSelector/Node/PseudoNode.php @@ -11,221 +11,65 @@ namespace Symfony\Component\CssSelector\Node; -use Symfony\Component\CssSelector\Exception\ParseException; -use Symfony\Component\CssSelector\XPathExpr; - /** - * PseudoNode represents a "selector:ident" node. + * Represents a ":" node. * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. * - * @author Fabien Potencier + * @author Jean-François Simon */ -class PseudoNode implements NodeInterface +class PseudoNode extends AbstractNode { - protected static $unsupported = array( - 'indeterminate', 'first-line', 'first-letter', - 'selection', 'before', 'after', 'link', 'visited', - 'active', 'focus', 'hover', - ); - - protected $element; - protected $type; - protected $ident; - - /** - * Constructor. - * - * @param NodeInterface $element The NodeInterface element - * @param string $type Node type - * @param string $ident The ident - * - * @throws ParseException When incorrect PseudoNode type is given - */ - public function __construct($element, $type, $ident) - { - $this->element = $element; - - if (!in_array($type, array(':', '::'))) { - throw new ParseException(sprintf('The PseudoNode type can only be : or :: (%s given).', $type)); - } - - $this->type = $type; - $this->ident = $ident; - } - - /** - * {@inheritDoc} - */ - public function __toString() - { - return sprintf('%s[%s%s%s]', __CLASS__, $this->element, $this->type, $this->ident); - } - - /** - * {@inheritDoc} - * @throws ParseException When unsupported or unknown pseudo-class is found - */ - public function toXpath() - { - $elXpath = $this->element->toXpath(); - - if (in_array($this->ident, self::$unsupported)) { - throw new ParseException(sprintf('The pseudo-class %s is unsupported', $this->ident)); - } - $method = 'xpath_'.str_replace('-', '_', $this->ident); - if (!method_exists($this, $method)) { - throw new ParseException(sprintf('The pseudo-class %s is unknown', $this->ident)); - } - - return $this->$method($elXpath); - } - /** - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified XPath expression + * @var NodeInterface */ - protected function xpath_checked($xpath) - { - // FIXME: is this really all the elements? - $xpath->addCondition("(@selected or @checked) and (name(.) = 'input' or name(.) = 'option')"); - - return $xpath; - } + private $selector; /** - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified XPath expression - * - * @throws ParseException If this element is the root element + * @var string */ - protected function xpath_root($xpath) - { - // if this element is the root element - throw new ParseException(); - } + private $identifier; /** - * Marks this XPath expression as the first child. - * - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified expression + * @param NodeInterface $selector + * @param string $identifier */ - protected function xpath_first_child($xpath) + public function __construct(NodeInterface $selector, $identifier) { - $xpath->addStarPrefix(); - $xpath->addNameTest(); - $xpath->addCondition('position() = 1'); - - return $xpath; + $this->selector = $selector; + $this->identifier = strtolower($identifier); } /** - * Sets the XPath to be the last child. - * - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified expression + * @return NodeInterface */ - protected function xpath_last_child($xpath) + public function getSelector() { - $xpath->addStarPrefix(); - $xpath->addNameTest(); - $xpath->addCondition('position() = last()'); - - return $xpath; + return $this->selector; } /** - * Sets the XPath expression to be the first of type. - * - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified expression - * - * @throws ParseException + * @return string */ - protected function xpath_first_of_type($xpath) + public function getIdentifier() { - if ($xpath->getElement() == '*') { - throw new ParseException('*:first-of-type is not implemented'); - } - $xpath->addStarPrefix(); - $xpath->addCondition('position() = 1'); - - return $xpath; + return $this->identifier; } /** - * Sets the XPath expression to be the last of type. - * - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified expression - * - * @throws ParseException Because *:last-of-type is not implemented + * {@inheritdoc} */ - protected function xpath_last_of_type($xpath) + public function getSpecificity() { - if ($xpath->getElement() == '*') { - throw new ParseException('*:last-of-type is not implemented'); - } - $xpath->addStarPrefix(); - $xpath->addCondition('position() = last()'); - - return $xpath; + return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0)); } /** - * Sets the XPath expression to be the only child. - * - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified expression + * {@inheritdoc} */ - protected function xpath_only_child($xpath) - { - $xpath->addNameTest(); - $xpath->addStarPrefix(); - $xpath->addCondition('last() = 1'); - - return $xpath; - } - - /** - * Sets the XPath expression to be only of type. - * - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified expression - * - * @throws ParseException Because *:only-of-type is not implemented - */ - protected function xpath_only_of_type($xpath) - { - if ($xpath->getElement() == '*') { - throw new ParseException('*:only-of-type is not implemented'); - } - $xpath->addCondition('last() = 1'); - - return $xpath; - } - - /** - * undocumented function - * - * @param XPathExpr $xpath The XPath expression - * - * @return XPathExpr The modified expression - */ - protected function xpath_empty($xpath) + public function __toString() { - $xpath->addCondition('not(*) and not(normalize-space())'); - - return $xpath; + return sprintf('%s[%s:%s]', $this->getNodeName(), $this->selector, $this->identifier); } } diff --git a/src/Symfony/Component/CssSelector/Node/SelectorNode.php b/src/Symfony/Component/CssSelector/Node/SelectorNode.php new file mode 100644 index 000000000000..49f417f2860c --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/SelectorNode.php @@ -0,0 +1,75 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class SelectorNode extends AbstractNode +{ + /** + * @var NodeInterface + */ + private $tree; + + /** + * @var null|string + */ + private $pseudoElement; + + /** + * @param NodeInterface $tree + * @param null|string $pseudoElement + */ + public function __construct(NodeInterface $tree, $pseudoElement = null) + { + $this->tree = $tree; + $this->pseudoElement = $pseudoElement ? strtolower($pseudoElement) : null; + } + + /** + * @return NodeInterface + */ + public function getTree() + { + return $this->tree; + } + + /** + * @return null|string + */ + public function getPseudoElement() + { + return $this->pseudoElement; + } + + /** + * {@inheritdoc} + */ + public function getSpecificity() + { + return $this->tree->getSpecificity()->plus(new Specificity(0, 0, $this->pseudoElement ? 1 : 0)); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return sprintf('%s[%s%s]', $this->getNodeName(), $this->tree, $this->pseudoElement ? '::'.$this->pseudoElement : ''); + } +} diff --git a/src/Symfony/Component/CssSelector/Node/Specificity.php b/src/Symfony/Component/CssSelector/Node/Specificity.php new file mode 100644 index 000000000000..96bbd11f515c --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/Specificity.php @@ -0,0 +1,78 @@ + + * + * 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 cssselector 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 + */ +class Specificity +{ + const A_FACTOR = 100; + const B_FACTOR = 10; + const C_FACTOR = 1; + + /** + * @var int + */ + private $a; + + /** + * @var int + */ + private $b; + + /** + * @var int + */ + private $c; + + /** + * Constructor. + * + * @param int $a + * @param int $b + * @param int $c + */ + public function __construct($a, $b, $c) + { + $this->a = $a; + $this->b = $b; + $this->c = $c; + } + + /** + * @param Specificity $specificity + * + * @return Specificity + */ + public function plus(Specificity $specificity) + { + return new self($this->a + $specificity->a, $this->b + $specificity->b, $this->c + $specificity->c); + } + + /** + * Returns global specificity value. + * + * @return int + */ + public function getValue() + { + return $this->a * self::A_FACTOR + $this->b * self::B_FACTOR + $this->c * self::C_FACTOR; + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Handler/CommentHandler.php b/src/Symfony/Component/CssSelector/Parser/Handler/CommentHandler.php new file mode 100644 index 000000000000..8de3ab52d762 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Handler/CommentHandler.php @@ -0,0 +1,47 @@ + + * + * 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 comment handler. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class CommentHandler implements HandlerInterface +{ + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream) + { + if ('/*' !== $reader->getSubstring(2)) { + return false; + } + + $offset = $reader->getOffset('*/'); + + if (false === $offset) { + $reader->moveToEnd(); + } else { + $reader->moveForward($offset + 2); + } + + return true; + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Handler/HandlerInterface.php b/src/Symfony/Component/CssSelector/Parser/Handler/HandlerInterface.php new file mode 100644 index 000000000000..ef5ab59640a3 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Handler/HandlerInterface.php @@ -0,0 +1,35 @@ + + * + * 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 handler interface. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +interface HandlerInterface +{ + /** + * @param Reader $reader + * @param TokenStream $stream + * + * @return boolean + */ + public function handle(Reader $reader, TokenStream $stream); +} diff --git a/src/Symfony/Component/CssSelector/Parser/Handler/HashHandler.php b/src/Symfony/Component/CssSelector/Parser/Handler/HashHandler.php new file mode 100644 index 000000000000..2227ea62a6aa --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Handler/HashHandler.php @@ -0,0 +1,67 @@ + + * + * 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; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class HashHandler implements HandlerInterface +{ + /** + * @var TokenizerPatterns + */ + private $patterns; + + /** + * @var TokenizerEscaping + */ + private $escaping; + + /** + * @param TokenizerPatterns $patterns + * @param TokenizerEscaping $escaping + */ + public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) + { + $this->patterns = $patterns; + $this->escaping = $escaping; + } + + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream) + { + $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; + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Handler/IdentifierHandler.php b/src/Symfony/Component/CssSelector/Parser/Handler/IdentifierHandler.php new file mode 100644 index 000000000000..346532ec1512 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Handler/IdentifierHandler.php @@ -0,0 +1,67 @@ + + * + * 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; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class IdentifierHandler implements HandlerInterface +{ + /** + * @var TokenizerPatterns + */ + private $patterns; + + /** + * @var TokenizerEscaping + */ + private $escaping; + + /** + * @param TokenizerPatterns $patterns + * @param TokenizerEscaping $escaping + */ + public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) + { + $this->patterns = $patterns; + $this->escaping = $escaping; + } + + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream) + { + $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; + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Handler/NumberHandler.php b/src/Symfony/Component/CssSelector/Parser/Handler/NumberHandler.php new file mode 100644 index 000000000000..208f83c08fe7 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Handler/NumberHandler.php @@ -0,0 +1,58 @@ + + * + * 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; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class NumberHandler implements HandlerInterface +{ + /** + * @var TokenizerPatterns + */ + private $patterns; + + /** + * @param TokenizerPatterns $patterns + */ + public function __construct(TokenizerPatterns $patterns) + { + $this->patterns = $patterns; + } + + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream) + { + $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; + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Handler/StringHandler.php b/src/Symfony/Component/CssSelector/Parser/Handler/StringHandler.php new file mode 100644 index 000000000000..366be61efb46 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Handler/StringHandler.php @@ -0,0 +1,86 @@ + + * + * 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\TokenStream; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; + +/** + * CSS selector comment handler. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class StringHandler implements HandlerInterface +{ + /** + * @var TokenizerPatterns + */ + private $patterns; + + /** + * @var TokenizerEscaping + */ + private $escaping; + + /** + * @param TokenizerPatterns $patterns + * @param TokenizerEscaping $escaping + */ + public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) + { + $this->patterns = $patterns; + $this->escaping = $escaping; + } + + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream) + { + $quote = $reader->getSubstring(1); + + if (!in_array($quote, array("'", '"'))) { + return false; + } + + $reader->moveForward(1); + $match = $reader->findPattern($this->patterns->getQuotedStringPattern($quote)); + + if (!$match) { + throw new InternalErrorException('Should have found at least an empty match at '.$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; + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Handler/WhitespaceHandler.php b/src/Symfony/Component/CssSelector/Parser/Handler/WhitespaceHandler.php new file mode 100644 index 000000000000..806cfbb51352 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Handler/WhitespaceHandler.php @@ -0,0 +1,44 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class WhitespaceHandler implements HandlerInterface +{ + /** + * {@inheritdoc} + */ + public function handle(Reader $reader, TokenStream $stream) + { + $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; + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Parser.php b/src/Symfony/Component/CssSelector/Parser/Parser.php new file mode 100644 index 000000000000..34b365125793 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Parser.php @@ -0,0 +1,395 @@ + + * + * 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\SyntaxErrorException; +use Symfony\Component\CssSelector\Node; +use Symfony\Component\CssSelector\Parser\Shortcut; +use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer; + +/** + * CSS selector parser. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class Parser implements ParserInterface +{ + /** + * @var Tokenizer + */ + private $tokenizer; + + /** + * Constructor. + * + * @param null|Tokenizer $tokenizer + */ + public function __construct(Tokenizer $tokenizer = null) + { + $this->tokenizer = $tokenizer ?: new Tokenizer(); + } + + /** + * {@inheritdoc} + */ + public function parse($source) + { + $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 + * + * @return array + */ + public static function parseSeries(array $tokens) + { + foreach ($tokens as $token) { + if ($token->isString()) { + throw SyntaxErrorException::stringAsFunctionArgument(); + } + } + + $joined = trim(implode('', array_map(function (Token $token) { + return $token->getValue(); + }, $tokens))); + + $int = function ($string) { + if (!is_numeric($string)) { + throw SyntaxErrorException::stringAsFunctionArgument(); + } + + return (int) $string; + }; + + switch (true) { + case 'odd' === $joined: + return array(2, 1); + case 'even' === $joined: + return array(2, 0); + case 'n' === $joined: + return array(1, 0); + case false === strpos($joined, 'n'): + return array(0, $int($joined)); + } + + $split = explode('n', $joined); + $first = isset($split[0]) ? $split[0] : null; + + return array( + $first ? ('-' === $first || '+' === $first ? $int($first.'1') : $int($first)) : 1, + isset($split[1]) && $split[1] ? $int($split[1]) : 0 + ); + } + + /** + * Parses selector nodes. + * + * @param TokenStream $stream + * + * @return array + */ + private function parseSelectorList(TokenStream $stream) + { + $stream->skipWhitespace(); + $selectors = array(); + + while (true) { + $selectors[] = $this->parserSelectorNode($stream); + + if ($stream->getPeek()->isDelimiter(array(','))) { + $stream->getNext(); + $stream->skipWhitespace(); + } else { + break; + } + } + + return $selectors; + } + + /** + * Parses next selector or combined node. + * + * @param TokenStream $stream + * + * @throws SyntaxErrorException + * + * @return Node\SelectorNode + */ + private function parserSelectorNode(TokenStream $stream) + { + list($result, $pseudoElement) = $this->parseSimpleSelector($stream); + + while (true) { + $stream->skipWhitespace(); + $peek = $stream->getPeek(); + + if ($peek->isFileEnd() || $peek->isDelimiter(array(','))) { + break; + } + + if (null !== $pseudoElement) { + throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector'); + } + + if ($peek->isDelimiter(array('+', '>', '~'))) { + $combinator = $stream->getNext()->getValue(); + $stream->skipWhitespace(); + } else { + $combinator = ' '; + } + + list($nextSelector, $pseudoElement) = $this->parseSimpleSelector($stream); + $result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector); + } + + return new Node\SelectorNode($result, $pseudoElement); + } + + /** + * Parses next simple node (hash, class, pseudo, negation). + * + * @param TokenStream $stream + * @param boolean $insideNegation + * + * @throws SyntaxErrorException + * + * @return array + */ + private function parseSimpleSelector(TokenStream $stream, $insideNegation = false) + { + $stream->skipWhitespace(); + + $selectorStart = count($stream->getUsed()); + $result = $this->parseElementNode($stream); + $pseudoElement = null; + + while (true) { + $peek = $stream->getPeek(); + if ($peek->isWhitespace() + || $peek->isFileEnd() + || $peek->isDelimiter(array(',', '+', '>', '~')) + || ($insideNegation && $peek->isDelimiter(array(')'))) + ) { + 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(array('.'))) { + $stream->getNext(); + $result = new Node\ClassNode($result, $stream->getNextIdentifier()); + } elseif ($peek->isDelimiter(array('['))) { + $stream->getNext(); + $result = $this->parseAttributeNode($result, $stream); + } elseif ($peek->isDelimiter(array(':'))) { + $stream->getNext(); + + if ($stream->getPeek()->isDelimiter(array(':'))) { + $stream->getNext(); + $pseudoElement = $stream->getNextIdentifier(); + + continue; + } + + $identifier = $stream->getNextIdentifier(); + if (in_array(strtolower($identifier), array('first-line', 'first-letter', 'before', 'after'))) { + // 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(array('('))) { + $result = new Node\PseudoNode($result, $identifier); + + continue; + } + + $stream->getNext(); + $stream->skipWhitespace(); + + if ('not' === strtolower($identifier)) { + if ($insideNegation) { + throw SyntaxErrorException::nestedNot(); + } + + list($argument, $argumentPseudoElement) = $this->parseSimpleSelector($stream, true); + $next = $stream->getNext(); + + if (null !== $argumentPseudoElement) { + throw SyntaxErrorException::pseudoElementFound($argumentPseudoElement, 'inside ::not()'); + } + + if (!$next->isDelimiter(array(')'))) { + throw SyntaxErrorException::unexpectedToken('")"', $next); + } + + $result = new Node\NegationNode($result, $argument); + } else { + $arguments = array(); + $next = null; + + while (true) { + $stream->skipWhitespace(); + $next = $stream->getNext(); + + if ($next->isIdentifier() + || $next->isString() + || $next->isNumber() + || $next->isDelimiter(array('+', '-')) + ) { + $arguments[] = $next; + } elseif ($next->isDelimiter(array(')'))) { + break; + } else { + throw SyntaxErrorException::unexpectedToken('an argument', $next); + } + } + + if (empty($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 array($result, $pseudoElement); + } + + /** + * Parses next element node. + * + * @param TokenStream $stream + * + * @return Node\ElementNode + */ + private function parseElementNode(TokenStream $stream) + { + $peek = $stream->getPeek(); + + if ($peek->isIdentifier() || $peek->isDelimiter(array('*'))) { + if ($peek->isIdentifier()) { + $namespace = $stream->getNext()->getValue(); + } else { + $stream->getNext(); + $namespace = null; + } + + if ($stream->getPeek()->isDelimiter(array('|'))) { + $stream->getNext(); + $element = $stream->getNextIdentifierOrStar(); + } else { + $element = $namespace; + $namespace = null; + } + } else { + $element = $namespace = null; + } + + return new Node\ElementNode($namespace, $element); + } + + /** + * Parses next attribute node. + * + * @param Node\NodeInterface $selector + * @param TokenStream $stream + * + * @throws SyntaxErrorException + * + * @return Node\AttributeNode + */ + private function parseAttributeNode(Node\NodeInterface $selector, TokenStream $stream) + { + $stream->skipWhitespace(); + $attribute = $stream->getNextIdentifierOrStar(); + + if (null === $attribute && !$stream->getPeek()->isDelimiter(array('|'))) { + throw SyntaxErrorException::unexpectedToken('"|"', $stream->getPeek()); + } + + if ($stream->getPeek()->isDelimiter(array('|'))) { + $stream->getNext(); + + if ($stream->getPeek()->isDelimiter(array('='))) { + $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(array(']'))) { + return new Node\AttributeNode($selector, $namespace, $attribute, 'exists', null); + } elseif ($next->isDelimiter(array('='))) { + $operator = '='; + } elseif ($next->isDelimiter(array('^', '$', '*', '~', '|', '!')) + && $stream->getPeek()->isDelimiter(array('=')) + ) { + $operator = $next->getValue().'='; + $stream->getNext(); + } else { + throw SyntaxErrorException::unexpectedToken('operator', $next); + } + } + + $stream->skipWhitespace(); + $value = $stream->getNext(); + + if (!($value->isIdentifier() || $value->isString())) { + throw SyntaxErrorException::unexpectedToken('string or identifier', $value); + } + + $stream->skipWhitespace(); + $next = $stream->getNext(); + + if (!$next->isDelimiter(array(']'))) { + throw SyntaxErrorException::unexpectedToken('"]"', $next); + } + + return new Node\AttributeNode($selector, $namespace, $attribute, $operator, $value->getValue()); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/ParserInterface.php b/src/Symfony/Component/CssSelector/Parser/ParserInterface.php new file mode 100644 index 000000000000..b27f79f4a6e4 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/ParserInterface.php @@ -0,0 +1,34 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +interface ParserInterface +{ + /** + * Parses given selector source into an array of tokens. + * + * @param string $source + * + * @return SelectorNode[] + */ + public function parse($source); +} diff --git a/src/Symfony/Component/CssSelector/Parser/Reader.php b/src/Symfony/Component/CssSelector/Parser/Reader.php new file mode 100644 index 000000000000..8bd8ed66988a --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Reader.php @@ -0,0 +1,126 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class Reader +{ + /** + * @var string + */ + private $source; + + /** + * @var int + */ + private $length; + + /** + * @var int + */ + private $position; + + /** + * @param string $source + */ + public function __construct($source) + { + $this->source = $source; + $this->length = strlen($source); + $this->position = 0; + } + + /** + * @return bool + */ + public function isEOF() + { + return $this->position >= $this->length; + } + + /** + * @return int + */ + public function getPosition() + { + return $this->position; + } + + /** + * @return int + */ + public function getRemainingLength() + { + return $this->length - $this->position; + } + + /** + * @param int $length + * @param int $offset + * + * @return string + */ + public function getSubstring($length, $offset = 0) + { + return substr($this->source, $this->position + $offset, $length); + } + + /** + * @param string $string + * + * @return int + */ + public function getOffset($string) + { + $position = strpos($this->source, $string, $this->position); + + return false === $position ? false : $position - $this->position; + } + + /** + * @param string $pattern + * + * @return bool + */ + public function findPattern($pattern) + { + $source = substr($this->source, $this->position); + + if (preg_match($pattern, $source, $matches)) { + return $matches; + } + + return false; + } + + /** + * @param int $length + */ + public function moveForward($length) + { + $this->position += $length; + } + + /** + */ + public function moveToEnd() + { + $this->position = $this->length; + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Shortcut/ClassParser.php b/src/Symfony/Component/CssSelector/Parser/Shortcut/ClassParser.php new file mode 100644 index 000000000000..76b7de96ed9a --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Shortcut/ClassParser.php @@ -0,0 +1,42 @@ + + * + * 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; +use Symfony\Component\CssSelector\Parser\Token; + +/** + * CSS selector class parser shortcut. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class ClassParser implements ParserInterface +{ + /** + * {@inheritdoc} + */ + public function parse($source) + { + // matches "." + if (preg_match('~^[ \t\r\n\f]*([a-zA-Z]*)\.([a-zA-Z][a-zA-Z0-9_-]*)[ \t\r\n\f]*$~', $source, $matches)) { + return array(new SelectorNode(new ClassNode(new ElementNode($matches[1] ?: null), $matches[2]))); + } + + return array(); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Shortcut/ElementParser.php b/src/Symfony/Component/CssSelector/Parser/Shortcut/ElementParser.php new file mode 100644 index 000000000000..15392edaa662 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Shortcut/ElementParser.php @@ -0,0 +1,41 @@ + + * + * 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; +use Symfony\Component\CssSelector\Parser\Token; + +/** + * CSS selector element parser shortcut. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class ElementParser implements ParserInterface +{ + /** + * {@inheritdoc} + */ + public function parse($source) + { + // matches "" + if (preg_match('~^[ \t\r\n\f]*([a-zA-Z][a-zA-Z0-9_-]*|\\*)[ \t\r\n\f]*$~', $source, $matches)) { + return array(new SelectorNode(new ElementNode(null, $matches[1]))); + } + + return array(); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Shortcut/EmptyStringParser.php b/src/Symfony/Component/CssSelector/Parser/Shortcut/EmptyStringParser.php new file mode 100644 index 000000000000..598a0406850a --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Shortcut/EmptyStringParser.php @@ -0,0 +1,45 @@ + + * + * 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; +use Symfony\Component\CssSelector\Parser\Token; + +/** + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class EmptyStringParser implements ParserInterface +{ + /** + * {@inheritdoc} + */ + public function parse($source) + { + // matches "" + if (preg_match('~^$~', $source, $matches)) { + return array(new SelectorNode(new ElementNode(null, '*'))); + } + + return array(); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Shortcut/HashParser.php b/src/Symfony/Component/CssSelector/Parser/Shortcut/HashParser.php new file mode 100644 index 000000000000..9730bcfc2b53 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Shortcut/HashParser.php @@ -0,0 +1,42 @@ + + * + * 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; +use Symfony\Component\CssSelector\Parser\Token; + +/** + * CSS selector hash parser shortcut. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class HashParser implements ParserInterface +{ + /** + * {@inheritdoc} + */ + public function parse($source) + { + // matches "#" + if (preg_match('~^[ \t\r\n\f]*([a-zA-Z][a-zA-Z0-9_-]*|\\*)?#([a-zA-Z0-9_-]+)[ \t\r\n\f]*$~', $source, $matches)) { + return array(new SelectorNode(new HashNode(new ElementNode(null, $matches[1] ?: null), $matches[2]))); + } + + return array(); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Token.php b/src/Symfony/Component/CssSelector/Parser/Token.php new file mode 100644 index 000000000000..0abe190a4bce --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Token.php @@ -0,0 +1,160 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class Token +{ + const TYPE_FILE_END = 'eof'; + const TYPE_DELIMITER = 'delimiter'; + const TYPE_WHITESPACE = 'whitespace'; + const TYPE_IDENTIFIER = 'identifier'; + const TYPE_HASH = 'hash'; + const TYPE_NUMBER = 'number'; + const TYPE_STRING = 'string'; + + /** + * @var int + */ + private $type; + + /** + * @var string + */ + private $value; + + /** + * @var int + */ + private $position; + + /** + * @param int $type + * @param string $value + * @param int $position + */ + public function __construct($type, $value, $position) + { + $this->type = $type; + $this->value = $value; + $this->position = $position; + } + + /** + * @return int + */ + public function getType() + { + return $this->type; + } + + /** + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * @return int + */ + public function getPosition() + { + return $this->position; + } + + /** + * @return boolean + */ + public function isFileEnd() + { + return self::TYPE_FILE_END === $this->type; + } + + /** + * @param array $values + * + * @return boolean + */ + public function isDelimiter(array $values = array()) + { + if (self::TYPE_DELIMITER !== $this->type) { + return false; + } + + if (empty($values)) { + return true; + } + + return in_array($this->value, $values); + } + + /** + * @return boolean + */ + public function isWhitespace() + { + return self::TYPE_WHITESPACE === $this->type; + } + + /** + * @return boolean + */ + public function isIdentifier() + { + return self::TYPE_IDENTIFIER === $this->type; + } + + /** + * @return boolean + */ + public function isHash() + { + return self::TYPE_HASH === $this->type; + } + + /** + * @return boolean + */ + public function isNumber() + { + return self::TYPE_NUMBER === $this->type; + } + + /** + * @return boolean + */ + public function isString() + { + return self::TYPE_STRING === $this->type; + } + + /** + * @return string + */ + public function __toString() + { + if ($this->value) { + return sprintf('<%s "%s" at %s>', $this->type, $this->value, $this->position); + } + + return sprintf('<%s at %s>', $this->type, $this->position); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/TokenStream.php b/src/Symfony/Component/CssSelector/Parser/TokenStream.php new file mode 100644 index 000000000000..40f11525c49b --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/TokenStream.php @@ -0,0 +1,182 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class TokenStream +{ + /** + * @var Token[] + */ + private $tokens = array(); + + /** + * @var boolean + */ + private $frozen = false; + + /** + * @var Token[] + */ + private $used = array(); + + /** + * @var int + */ + private $cursor = 0; + + /** + * @var Token|null + */ + private $peeked = null; + + /** + * @var boolean + */ + private $peeking = false; + + /** + * Pushes a token. + * + * @param Token $token + * + * @return TokenStream + */ + public function push(Token $token) + { + $this->tokens[] = $token; + + return $this; + } + + /** + * Freezes stream. + * + * @return TokenStream + */ + public function freeze() + { + $this->frozen = true; + + return $this; + } + + /** + * Returns next token. + * + * @throws InternalErrorException If there is no more token + * + * @return Token + */ + public function getNext() + { + 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. + * + * @return Token + */ + public function getPeek() + { + if (!$this->peeking) { + $this->peeked = $this->getNext(); + $this->peeking = true; + } + + return $this->peeked; + } + + /** + * Returns used tokens. + * + * @return Token[] + */ + public function getUsed() + { + return $this->used; + } + + /** + * Returns nex identifier token. + * + * @throws SyntaxErrorException If next token is not an identifier + * + * @return string The identifier token value + */ + public function getNextIdentifier() + { + $next = $this->getNext(); + + if (!$next->isIdentifier()) { + throw SyntaxErrorException::unexpectedToken('identifier', $next); + } + + return $next->getValue(); + } + + /** + * Returns nex identifier or star delimiter token. + * + * @throws SyntaxErrorException If next token is not an identifier or a star delimiter + * + * @return null|string The identifier token value or null if star found + */ + public function getNextIdentifierOrStar() + { + $next = $this->getNext(); + + if ($next->isIdentifier()) { + return $next->getValue(); + } + + if ($next->isDelimiter(array('*'))) { + return null; + } + + throw SyntaxErrorException::unexpectedToken('identifier or "*"', $next); + } + + /** + * Skips next whitespace if any. + */ + public function skipWhitespace() + { + $peek = $this->getPeek(); + + if ($peek->isWhitespace()) { + $this->getNext(); + } + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Tokenizer/Tokenizer.php b/src/Symfony/Component/CssSelector/Parser/Tokenizer/Tokenizer.php new file mode 100644 index 000000000000..c850276728c7 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Tokenizer/Tokenizer.php @@ -0,0 +1,78 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class Tokenizer +{ + /** + * @var Handler\HandlerInterface[] + */ + private $handlers; + + /** + * Constructor. + */ + public function __construct() + { + $patterns = new TokenizerPatterns(); + $escaping = new TokenizerEscaping($patterns); + + $this->handlers = array( + 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. + * + * @param Reader $reader + * + * @return TokenStream + */ + public function tokenize(Reader $reader) + { + $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(); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Tokenizer/TokenizerEscaping.php b/src/Symfony/Component/CssSelector/Parser/Tokenizer/TokenizerEscaping.php new file mode 100644 index 000000000000..c90fc1044d3a --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Tokenizer/TokenizerEscaping.php @@ -0,0 +1,78 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class TokenizerEscaping +{ + /** + * @var TokenizerPatterns + */ + private $patterns; + + /** + * @param TokenizerPatterns $patterns + */ + public function __construct(TokenizerPatterns $patterns) + { + $this->patterns = $patterns; + } + + /** + * @param string $value + * + * @return string + */ + public function escapeUnicode($value) + { + $value = $this->replaceUnicodeSequences($value); + + return preg_replace($this->patterns->getSimpleEscapePattern(), '$1', $value); + } + + /** + * @param string $value + * + * @return string + */ + public function escapeUnicodeAndNewLine($value) + { + $value = preg_replace($this->patterns->getNewLineEscapePattern(), '', $value); + + return $this->escapeUnicode($value); + } + + /** + * @param string $value + * + * @return string + */ + private function replaceUnicodeSequences($value) + { + return preg_replace_callback($this->patterns->getUnicodeEscapePattern(), function (array $match) { + $code = $match[1]; + + if (bin2hex($code) > 0xFFFD) { + $code = '\\FFFD'; + } + + return mb_convert_encoding(pack('H*', $code), 'UTF-8', 'UCS-2BE'); + }, $value); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Tokenizer/TokenizerPatterns.php b/src/Symfony/Component/CssSelector/Parser/Tokenizer/TokenizerPatterns.php new file mode 100644 index 000000000000..6fc98b71e126 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Parser/Tokenizer/TokenizerPatterns.php @@ -0,0 +1,160 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class TokenizerPatterns +{ + /** + * @var string + */ + private $unicodeEscapePattern; + + /** + * @var string + */ + private $simpleEscapePattern; + + /** + * @var string + */ + private $newLineEscapePattern; + + /** + * @var string + */ + private $escapePattern; + + /** + * @var string + */ + private $stringEscapePattern; + + /** + * @var string + */ + private $nonAsciiPattern; + + /** + * @var string + */ + private $nmCharPattern; + + /** + * @var string + */ + private $nmStartPattern; + + /** + * @var string + */ + private $identifierPattern; + + /** + * @var string + */ + private $hashPattern; + + /** + * @var string + */ + private $numberPattern; + + /** + * @var string + */ + private $quotedStringPattern; + + /** + * Constructor. + */ + 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.')*'; + } + + /** + * @return string + */ + public function getNewLineEscapePattern() + { + return '~^'.$this->newLineEscapePattern.'~'; + } + + /** + * @return string + */ + public function getSimpleEscapePattern() + { + return '~^'.$this->simpleEscapePattern.'~'; + } + + /** + * @return string + */ + public function getUnicodeEscapePattern() + { + return '~^'.$this->unicodeEscapePattern.'~i'; + } + + /** + * @return string + */ + public function getIdentifierPattern() + { + return '~^'.$this->identifierPattern.'~i'; + } + + /** + * @return string + */ + public function getHashPattern() + { + return '~^'.$this->hashPattern.'~i'; + } + + /** + * @return string + */ + public function getNumberPattern() + { + return '~^'.$this->numberPattern.'~'; + } + + /** + * @param string $quote + * + * @return string + */ + public function getQuotedStringPattern($quote) + { + return '~^'.sprintf($this->quotedStringPattern, $quote).'~i'; + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/CssSelectorTest.php b/src/Symfony/Component/CssSelector/Tests/CssSelectorTest.php deleted file mode 100644 index 0d9ca851aac0..000000000000 --- a/src/Symfony/Component/CssSelector/Tests/CssSelectorTest.php +++ /dev/null @@ -1,71 +0,0 @@ - - * - * 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 Symfony\Component\CssSelector\CssSelector; - -class CssSelectorTest extends \PHPUnit_Framework_TestCase -{ - public function testCsstoXPath() - { - $this->assertEquals('descendant-or-self::*', CssSelector::toXPath('')); - $this->assertEquals('descendant-or-self::h1', CssSelector::toXPath('h1')); - $this->assertEquals("descendant-or-self::h1[@id = 'foo']", CssSelector::toXPath('h1#foo')); - $this->assertEquals("descendant-or-self::h1[contains(concat(' ', normalize-space(@class), ' '), ' foo ')]", CssSelector::toXPath('h1.foo')); - - $this->assertEquals('descendant-or-self::foo:h1', CssSelector::toXPath('foo|h1')); - } - - /** - * @dataProvider getCssSelectors - */ - public function testParse($css, $xpath) - { - $parser = new CssSelector(); - - $this->assertEquals($xpath, (string) $parser->parse($css)->toXPath(), '->parse() parses an input string and returns a node'); - } - - public function testParseExceptions() - { - $parser = new CssSelector(); - - try { - $parser->parse('h1:'); - $this->fail('->parse() throws an Exception if the css selector is not valid'); - } catch (\Exception $e) { - $this->assertInstanceOf('\Symfony\Component\CssSelector\Exception\ParseException', $e, '->parse() throws an Exception if the css selector is not valid'); - $this->assertEquals("Expected symbol, got '' at h1: -> ", $e->getMessage(), '->parse() throws an Exception if the css selector is not valid'); - } - } - - public function getCssSelectors() - { - return array( - array('h1', "h1"), - array('foo|h1', "foo:h1"), - array('h1, h2, h3', "h1 | h2 | h3"), - array('h1:nth-child(3n+1)', "*/*[name() = 'h1' and ((position() -1) mod 3 = 0 and position() >= 1)]"), - array('h1 > p', "h1/p"), - array('h1#foo', "h1[@id = 'foo']"), - array('h1.foo', "h1[contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"), - array('h1[class*="foo bar"]', "h1[contains(@class, 'foo bar')]"), - array('h1[foo|class*="foo bar"]', "h1[contains(@foo:class, 'foo bar')]"), - array('h1[class]', "h1[@class]"), - array('h1 .foo', "h1/descendant::*[contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"), - array('h1 #foo', "h1/descendant::*[@id = 'foo']"), - array('h1 [class*=foo]', "h1/descendant::*[contains(@class, 'foo')]"), - array('div>.foo', "div/*[contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"), - array('div > .foo', "div/*[contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"), - ); - } -} diff --git a/src/Symfony/Component/CssSelector/Tests/Node/AbstractNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/AbstractNodeTest.php new file mode 100644 index 000000000000..16a3a34d6497 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Node/AbstractNodeTest.php @@ -0,0 +1,32 @@ + + * + * 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\NodeInterface; + +abstract class AbstractNodeTest extends \PHPUnit_Framework_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 function getToStringConversionTestData(); + abstract public function getSpecificityValueTestData(); +} diff --git a/src/Symfony/Component/CssSelector/Tests/Node/AttribNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/AttribNodeTest.php deleted file mode 100644 index 2f19fe259dec..000000000000 --- a/src/Symfony/Component/CssSelector/Tests/Node/AttribNodeTest.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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\AttribNode; -use Symfony\Component\CssSelector\Node\ElementNode; - -class AttribNodeTest extends \PHPUnit_Framework_TestCase -{ - public function testToXpath() - { - $element = new ElementNode('*', 'h1'); - - $operators = array( - '^=' => "h1[starts-with(@class, 'foo')]", - '$=' => "h1[substring(@class, string-length(@class)-2) = 'foo']", - '*=' => "h1[contains(@class, 'foo')]", - '=' => "h1[@class = 'foo']", - '~=' => "h1[contains(concat(' ', normalize-space(@class), ' '), ' foo ')]", - '|=' => "h1[@class = 'foo' or starts-with(@class, 'foo-')]", - '!=' => "h1[not(@class) or @class != 'foo']", - ); - - // h1[class??foo] - foreach ($operators as $op => $xpath) { - $attrib = new AttribNode($element, '*', 'class', $op, 'foo'); - $this->assertEquals($xpath, (string) $attrib->toXpath(), '->toXpath() returns the xpath representation of the node'); - } - - // h1[class] - $attrib = new AttribNode($element, '*', 'class', 'exists', 'foo'); - $this->assertEquals('h1[@class]', (string) $attrib->toXpath(), '->toXpath() returns the xpath representation of the node'); - } -} diff --git a/src/Symfony/Component/CssSelector/Tests/Node/AttributeNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/AttributeNodeTest.php new file mode 100644 index 000000000000..1fd090f5a6e8 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Node/AttributeNodeTest.php @@ -0,0 +1,37 @@ + + * + * 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 AbstractNodeTest +{ + public function getToStringConversionTestData() + { + return array( + array(new AttributeNode(new ElementNode(), null, 'attribute', 'exists', null), 'Attribute[Element[*][attribute]]'), + array(new AttributeNode(new ElementNode(), null, 'attribute', '$=', 'value'), "Attribute[Element[*][attribute $= 'value']]"), + array(new AttributeNode(new ElementNode(), 'namespace', 'attribute', '$=', 'value'), "Attribute[Element[*][namespace|attribute $= 'value']]"), + ); + } + + public function getSpecificityValueTestData() + { + return array( + array(new AttributeNode(new ElementNode(), null, 'attribute', 'exists', null), 10), + array(new AttributeNode(new ElementNode(null, 'element'), null, 'attribute', 'exists', null), 11), + array(new AttributeNode(new ElementNode(), null, 'attribute', '$=', 'value'), 10), + array(new AttributeNode(new ElementNode(), 'namespace', 'attribute', '$=', 'value'), 10), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Node/ClassNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/ClassNodeTest.php index c0a96f16ece0..e0ab45accc31 100644 --- a/src/Symfony/Component/CssSelector/Tests/Node/ClassNodeTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Node/ClassNodeTest.php @@ -14,14 +14,20 @@ use Symfony\Component\CssSelector\Node\ClassNode; use Symfony\Component\CssSelector\Node\ElementNode; -class ClassNodeTest extends \PHPUnit_Framework_TestCase +class ClassNodeTest extends AbstractNodeTest { - public function testToXpath() + public function getToStringConversionTestData() { - // h1.foo - $element = new ElementNode('*', 'h1'); - $class = new ClassNode($element, 'foo'); + return array( + array(new ClassNode(new ElementNode(), 'class'), 'Class[Element[*].class]'), + ); + } - $this->assertEquals("h1[contains(concat(' ', normalize-space(@class), ' '), ' foo ')]", (string) $class->toXpath(), '->toXpath() returns the xpath representation of the node'); + public function getSpecificityValueTestData() + { + return array( + array(new ClassNode(new ElementNode(), 'class'), 10), + array(new ClassNode(new ElementNode(null, 'element'), 'class'), 11), + ); } } diff --git a/src/Symfony/Component/CssSelector/Tests/Node/CombinedSelectorNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/CombinedSelectorNodeTest.php index 28f4e289ceb8..9547298a6fdf 100644 --- a/src/Symfony/Component/CssSelector/Tests/Node/CombinedSelectorNodeTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Node/CombinedSelectorNodeTest.php @@ -14,23 +14,22 @@ use Symfony\Component\CssSelector\Node\CombinedSelectorNode; use Symfony\Component\CssSelector\Node\ElementNode; -class CombinedSelectorNodeTest extends \PHPUnit_Framework_TestCase +class CombinedSelectorNodeTest extends AbstractNodeTest { - public function testToXpath() + public function getToStringConversionTestData() { - $combinators = array( - ' ' => "h1/descendant::p", - '>' => "h1/p", - '+' => "h1/following-sibling::*[name() = 'p' and (position() = 1)]", - '~' => "h1/following-sibling::p", + return array( + array(new CombinedSelectorNode(new ElementNode(), '>', new ElementNode()), 'CombinedSelector[Element[*] > Element[*]]'), + array(new CombinedSelectorNode(new ElementNode(), ' ', new ElementNode()), 'CombinedSelector[Element[*] Element[*]]'), ); + } - // h1 ?? p - $element1 = new ElementNode('*', 'h1'); - $element2 = new ElementNode('*', 'p'); - foreach ($combinators as $combinator => $xpath) { - $combinator = new CombinedSelectorNode($element1, $combinator, $element2); - $this->assertEquals($xpath, (string) $combinator->toXpath(), '->toXpath() returns the xpath representation of the node'); - } + public function getSpecificityValueTestData() + { + return array( + array(new CombinedSelectorNode(new ElementNode(), '>', new ElementNode()), 0), + array(new CombinedSelectorNode(new ElementNode(null, 'element'), '>', new ElementNode()), 1), + array(new CombinedSelectorNode(new ElementNode(null, 'element'), '>', new ElementNode(null, 'element')), 2), + ); } } diff --git a/src/Symfony/Component/CssSelector/Tests/Node/ElementNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/ElementNodeTest.php index 5d23e3f6c99c..1db6a591a2f5 100644 --- a/src/Symfony/Component/CssSelector/Tests/Node/ElementNodeTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Node/ElementNodeTest.php @@ -13,18 +13,23 @@ use Symfony\Component\CssSelector\Node\ElementNode; -class ElementNodeTest extends \PHPUnit_Framework_TestCase +class ElementNodeTest extends AbstractNodeTest { - public function testToXpath() + public function getToStringConversionTestData() { - // h1 - $element = new ElementNode('*', 'h1'); - - $this->assertEquals('h1', (string) $element->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // foo|h1 - $element = new ElementNode('foo', 'h1'); + return array( + array(new ElementNode(), 'Element[*]'), + array(new ElementNode(null, 'element'), 'Element[element]'), + array(new ElementNode('namespace', 'element'), 'Element[namespace|element]'), + ); + } - $this->assertEquals('foo:h1', (string) $element->toXpath(), '->toXpath() returns the xpath representation of the node'); + public function getSpecificityValueTestData() + { + return array( + array(new ElementNode(), 0), + array(new ElementNode(null, 'element'), 1), + array(new ElementNode('namespace', 'element'),1), + ); } } diff --git a/src/Symfony/Component/CssSelector/Tests/Node/FunctionNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/FunctionNodeTest.php index 9654402ae023..ee3ce51ba549 100644 --- a/src/Symfony/Component/CssSelector/Tests/Node/FunctionNodeTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Node/FunctionNodeTest.php @@ -11,86 +11,37 @@ namespace Symfony\Component\CssSelector\Tests\Node; -use Symfony\Component\CssSelector\Node\FunctionNode; use Symfony\Component\CssSelector\Node\ElementNode; -use Symfony\Component\CssSelector\Token; +use Symfony\Component\CssSelector\Node\FunctionNode; +use Symfony\Component\CssSelector\Parser\Token; -class FunctionNodeTest extends \PHPUnit_Framework_TestCase +class FunctionNodeTest extends AbstractNodeTest { - public function testToXpath() + public function getToStringConversionTestData() { - $element = new ElementNode('*', 'h1'); - - // h1:contains("foo") - $function = new FunctionNode($element, ':', 'contains', 'foo'); - $this->assertEquals("h1[contains(string(.), 'foo')]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(1) - $function = new FunctionNode($element, ':', 'nth-child', 1); - $this->assertEquals("*/*[name() = 'h1' and (position() = 1)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child() - $function = new FunctionNode($element, ':', 'nth-child', ''); - $this->assertEquals("h1[false() and position() = 0]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(odd) - $element2 = new ElementNode('*', new Token('Symbol', 'odd', -1)); - $function = new FunctionNode($element, ':', 'nth-child', $element2); - $this->assertEquals("*/*[name() = 'h1' and ((position() -1) mod 2 = 0 and position() >= 1)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(even) - $element2 = new ElementNode('*', new Token('Symbol', 'even', -1)); - $function = new FunctionNode($element, ':', 'nth-child', $element2); - $this->assertEquals("*/*[name() = 'h1' and ((position() +0) mod 2 = 0 and position() >= 0)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(n) - $element2 = new ElementNode('*', new Token('Symbol', 'n', -1)); - $function = new FunctionNode($element, ':', 'nth-child', $element2); - $this->assertEquals("*/*[name() = 'h1' and (position() >= 0)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(3n+1) - $element2 = new ElementNode('*', new Token('Symbol', '3n+1', -1)); - $function = new FunctionNode($element, ':', 'nth-child', $element2); - $this->assertEquals("*/*[name() = 'h1' and ((position() -1) mod 3 = 0 and position() >= 1)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(n+1) - $element2 = new ElementNode('*', new Token('Symbol', 'n+1', -1)); - $function = new FunctionNode($element, ':', 'nth-child', $element2); - $this->assertEquals("*/*[name() = 'h1' and (position() >= 1)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(1) - $element2 = new ElementNode('*', new Token('Symbol', '2', -1)); - $function = new FunctionNode($element, ':', 'nth-child', $element2); - $this->assertEquals("*/*[name() = 'h1' and (position() = 2)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(2n) - $element2 = new ElementNode('*', new Token('Symbol', '2n', -1)); - $function = new FunctionNode($element, ':', 'nth-child', $element2); - $this->assertEquals("*/*[name() = 'h1' and ((position() +0) mod 2 = 0 and position() >= 0)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-child(-n) - $element2 = new ElementNode('*', new Token('Symbol', '-n', -1)); - $function = new FunctionNode($element, ':', 'nth-child', $element2); - $this->assertEquals("*/*[name() = 'h1' and ((position() +0) mod -1 = 0 and position() >= 0)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-last-child(2) - $function = new FunctionNode($element, ':', 'nth-last-child', 2); - $this->assertEquals("*/*[name() = 'h1' and (position() = last() - 2)]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-of-type(2) - $function = new FunctionNode($element, ':', 'nth-of-type', 2); - $this->assertEquals("*/h1[position() = 2]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:nth-last-of-type(2) - $function = new FunctionNode($element, ':', 'nth-last-of-type', 2); - $this->assertEquals("*/h1[position() = last() - 2]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - - /* - // h1:not(p) - $element2 = new ElementNode('*', 'p'); - $function = new FunctionNode($element, ':', 'not', $element2); + return array( + array(new FunctionNode(new ElementNode(), 'function'), 'Function[Element[*]:function()]'), + array(new FunctionNode(new ElementNode(), 'function', array( + new Token(Token::TYPE_IDENTIFIER, 'value', 0), + )), "Function[Element[*]:function(['value'])]"), + array(new FunctionNode(new ElementNode(), 'function', array( + new Token(Token::TYPE_STRING, 'value1', 0), + new Token(Token::TYPE_NUMBER, 'value2', 0), + )), "Function[Element[*]:function(['value1', 'value2'])]"), + ); + } - $this->assertEquals("h1[not()]", (string) $function->toXpath(), '->toXpath() returns the xpath representation of the node'); - */ + public function getSpecificityValueTestData() + { + return array( + array(new FunctionNode(new ElementNode(), 'function'), 10), + array(new FunctionNode(new ElementNode(), 'function', array( + new Token(Token::TYPE_IDENTIFIER, 'value', 0), + )), 10), + array(new FunctionNode(new ElementNode(), 'function', array( + new Token(Token::TYPE_STRING, 'value1', 0), + new Token(Token::TYPE_NUMBER, 'value2', 0), + )), 10), + ); } } diff --git a/src/Symfony/Component/CssSelector/Tests/Node/HashNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/HashNodeTest.php index d91974774de8..8554b226d6c7 100644 --- a/src/Symfony/Component/CssSelector/Tests/Node/HashNodeTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Node/HashNodeTest.php @@ -14,14 +14,20 @@ use Symfony\Component\CssSelector\Node\HashNode; use Symfony\Component\CssSelector\Node\ElementNode; -class HashNodeTest extends \PHPUnit_Framework_TestCase +class HashNodeTest extends AbstractNodeTest { - public function testToXpath() + public function getToStringConversionTestData() { - // h1#foo - $element = new ElementNode('*', 'h1'); - $hash = new HashNode($element, 'foo'); + return array( + array(new HashNode(new ElementNode(), 'id'), 'Hash[Element[*]#id]'), + ); + } - $this->assertEquals("h1[@id = 'foo']", (string) $hash->toXpath(), '->toXpath() returns the xpath representation of the node'); + public function getSpecificityValueTestData() + { + return array( + array(new HashNode(new ElementNode(), 'id'), 100), + array(new HashNode(new ElementNode(null, 'id'), 'class'), 101), + ); } } diff --git a/src/Symfony/Component/CssSelector/Tests/Node/NegationNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/NegationNodeTest.php new file mode 100644 index 000000000000..edf4552bac8b --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Node/NegationNodeTest.php @@ -0,0 +1,33 @@ + + * + * 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\NegationNode; +use Symfony\Component\CssSelector\Node\ElementNode; + +class NegationNodeTest extends AbstractNodeTest +{ + public function getToStringConversionTestData() + { + return array( + array(new NegationNode(new ElementNode(), new ClassNode(new ElementNode(), 'class')), 'Negation[Element[*]:not(Class[Element[*].class])]'), + ); + } + + public function getSpecificityValueTestData() + { + return array( + array(new NegationNode(new ElementNode(), new ClassNode(new ElementNode(), 'class')), 10), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Node/OrNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/OrNodeTest.php deleted file mode 100644 index 9b9e6e32dff5..000000000000 --- a/src/Symfony/Component/CssSelector/Tests/Node/OrNodeTest.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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\OrNode; -use Symfony\Component\CssSelector\Node\ElementNode; - -class OrNodeTest extends \PHPUnit_Framework_TestCase -{ - public function testToXpath() - { - // h1, h2, h3 - $element1 = new ElementNode('*', 'h1'); - $element2 = new ElementNode('*', 'h2'); - $element3 = new ElementNode('*', 'h3'); - $or = new OrNode(array($element1, $element2, $element3)); - - $this->assertEquals("h1 | h2 | h3", (string) $or->toXpath(), '->toXpath() returns the xpath representation of the node'); - } - - public function testIssueMissingPrefix() - { - // h1, h2, h3 - $element1 = new ElementNode('*', 'h1'); - $element2 = new ElementNode('*', 'h2'); - $element3 = new ElementNode('*', 'h3'); - $or = new OrNode(array($element1, $element2, $element3)); - - $xPath = $or->toXPath(); - $xPath->addPrefix('descendant-or-self::'); - - $this->assertEquals("descendant-or-self::h1 | descendant-or-self::h2 | descendant-or-self::h3", (string) $xPath); - } -} diff --git a/src/Symfony/Component/CssSelector/Tests/Node/PseudoNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/PseudoNodeTest.php index 8bd0cec00ae4..bc57813cc8fe 100644 --- a/src/Symfony/Component/CssSelector/Tests/Node/PseudoNodeTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Node/PseudoNodeTest.php @@ -11,45 +11,22 @@ namespace Symfony\Component\CssSelector\Tests\Node; -use Symfony\Component\CssSelector\Node\PseudoNode; use Symfony\Component\CssSelector\Node\ElementNode; +use Symfony\Component\CssSelector\Node\PseudoNode; -class PseudoNodeTest extends \PHPUnit_Framework_TestCase +class PseudoNodeTest extends AbstractNodeTest { - public function testToXpath() + public function getToStringConversionTestData() { - $element = new ElementNode('*', 'h1'); - - // h1:checked - $pseudo = new PseudoNode($element, ':', 'checked'); - $this->assertEquals("h1[(@selected or @checked) and (name(.) = 'input' or name(.) = 'option')]", (string) $pseudo->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:first-child - $pseudo = new PseudoNode($element, ':', 'first-child'); - $this->assertEquals("*/*[name() = 'h1' and (position() = 1)]", (string) $pseudo->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:last-child - $pseudo = new PseudoNode($element, ':', 'last-child'); - $this->assertEquals("*/*[name() = 'h1' and (position() = last())]", (string) $pseudo->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:first-of-type - $pseudo = new PseudoNode($element, ':', 'first-of-type'); - $this->assertEquals("*/h1[position() = 1]", (string) $pseudo->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:last-of-type - $pseudo = new PseudoNode($element, ':', 'last-of-type'); - $this->assertEquals("*/h1[position() = last()]", (string) $pseudo->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:only-child - $pseudo = new PseudoNode($element, ':', 'only-child'); - $this->assertEquals("*/*[name() = 'h1' and (last() = 1)]", (string) $pseudo->toXpath(), '->toXpath() returns the xpath representation of the node'); - - // h1:only-of-type - $pseudo = new PseudoNode($element, ':', 'only-of-type'); - $this->assertEquals("h1[last() = 1]", (string) $pseudo->toXpath(), '->toXpath() returns the xpath representation of the node'); + return array( + array(new PseudoNode(new ElementNode(), 'pseudo'), 'Pseudo[Element[*]:pseudo]'), + ); + } - // h1:empty - $pseudo = new PseudoNode($element, ':', 'empty'); - $this->assertEquals("h1[not(*) and not(normalize-space())]", (string) $pseudo->toXpath(), '->toXpath() returns the xpath representation of the node'); + public function getSpecificityValueTestData() + { + return array( + array(new PseudoNode(new ElementNode(), 'pseudo'), 10), + ); } } diff --git a/src/Symfony/Component/CssSelector/Tests/Node/SelectorNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/SelectorNodeTest.php new file mode 100644 index 000000000000..5badf71d16d1 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Node/SelectorNodeTest.php @@ -0,0 +1,34 @@ + + * + * 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 AbstractNodeTest +{ + public function getToStringConversionTestData() + { + return array( + array(new SelectorNode(new ElementNode()), 'Selector[Element[*]]'), + array(new SelectorNode(new ElementNode(), 'pseudo'), 'Selector[Element[*]::pseudo]'), + ); + } + + public function getSpecificityValueTestData() + { + return array( + array(new SelectorNode(new ElementNode()), 0), + array(new SelectorNode(new ElementNode(), 'pseudo'), 1), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Node/SpecificityTest.php b/src/Symfony/Component/CssSelector/Tests/Node/SpecificityTest.php new file mode 100644 index 000000000000..1f200cffe4f0 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Node/SpecificityTest.php @@ -0,0 +1,40 @@ + + * + * 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\Specificity; + +class SpecificityTest extends \PHPUnit_Framework_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 function getValueTestData() + { + return array( + array(new Specificity(0, 0, 0), 0), + array(new Specificity(0, 0, 2), 2), + array(new Specificity(0, 3, 0), 30), + array(new Specificity(4, 0, 0), 400), + array(new Specificity(4, 3, 2), 432), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Handler/AbstractHandlerTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/AbstractHandlerTest.php new file mode 100644 index 000000000000..a0d80a1cd65a --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/AbstractHandlerTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Tests\Handler; + +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 AbstractHandlerTest extends \PHPUnit_Framework_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 function getHandleValueTestData(); + abstract public function getDontHandleValueTestData(); + abstract protected function generateHandler(); + + protected function assertStreamEmpty(TokenStream $stream) + { + $property = new \ReflectionProperty($stream, 'tokens'); + $property->setAccessible(true); + + $this->assertEquals(array(), $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)); + } + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Handler/CommentHandlerTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/CommentHandlerTest.php new file mode 100644 index 000000000000..27e53cdb7736 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/CommentHandlerTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Tests\Handler; + +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 AbstractHandlerTest +{ + /** @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 function getHandleValueTestData() + { + return array( + // 2nd argument only exists for inherited method compatibility + array('/* comment */', new Token(null, null, null), ''), + array('/* comment */foo', new Token(null, null, null), 'foo'), + ); + } + + public function getDontHandleValueTestData() + { + return array( + array('>'), + array('+'), + array(' '), + ); + } + + protected function generateHandler() + { + return new CommentHandler(); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Handler/HashHandlerTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/HashHandlerTest.php new file mode 100644 index 000000000000..04f71b763c04 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/HashHandlerTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Tests\Handler; + +use Symfony\Component\CssSelector\Parser\Handler\HashHandler; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; + +class HashHandlerTest extends AbstractHandlerTest +{ + public function getHandleValueTestData() + { + return array( + array('#id', new Token(Token::TYPE_HASH, 'id', 0), ''), + array('#123', new Token(Token::TYPE_HASH, '123', 0), ''), + + array('#id.class', new Token(Token::TYPE_HASH, 'id', 0), '.class'), + array('#id element', new Token(Token::TYPE_HASH, 'id', 0), ' element'), + ); + } + + public function getDontHandleValueTestData() + { + return array( + array('id'), + array('123'), + array('<'), + array('<'), + array('#'), + ); + } + + protected function generateHandler() + { + $patterns = new TokenizerPatterns(); + + return new HashHandler($patterns, new TokenizerEscaping($patterns)); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Handler/IdentifierHandlerTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/IdentifierHandlerTest.php new file mode 100644 index 000000000000..84ee1d88f6a0 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/IdentifierHandlerTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Tests\Handler; + +use Symfony\Component\CssSelector\Parser\Handler\IdentifierHandler; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; + +class IdentifierHandlerTest extends AbstractHandlerTest +{ + public function getHandleValueTestData() + { + return array( + array('foo', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), ''), + array('foo|bar', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '|bar'), + array('foo.class', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '.class'), + array('foo[attr]', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '[attr]'), + array('foo bar', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), ' bar'), + ); + } + + public function getDontHandleValueTestData() + { + return array( + array('>'), + array('+'), + array(' '), + array('*|foo'), + array('/* comment */'), + ); + } + + protected function generateHandler() + { + $patterns = new TokenizerPatterns(); + + return new IdentifierHandler($patterns, new TokenizerEscaping($patterns)); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Handler/NumberHandlerTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/NumberHandlerTest.php new file mode 100644 index 000000000000..e8782e84f37d --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/NumberHandlerTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Tests\Handler; + +use Symfony\Component\CssSelector\Parser\Handler\NumberHandler; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; + +class NumberHandlerTest extends AbstractHandlerTest +{ + public function getHandleValueTestData() + { + return array( + array('12', new Token(Token::TYPE_NUMBER, '12', 0), ''), + array('12.34', new Token(Token::TYPE_NUMBER, '12.34', 0), ''), + array('+12.34', new Token(Token::TYPE_NUMBER, '+12.34', 0), ''), + array('-12.34', new Token(Token::TYPE_NUMBER, '-12.34', 0), ''), + + array('12 arg', new Token(Token::TYPE_NUMBER, '12', 0), ' arg'), + array('12]', new Token(Token::TYPE_NUMBER, '12', 0), ']'), + ); + } + + public function getDontHandleValueTestData() + { + return array( + array('hello'), + array('>'), + array('+'), + array(' '), + array('/* comment */'), + ); + } + + protected function generateHandler() + { + $patterns = new TokenizerPatterns(); + + return new NumberHandler($patterns, new TokenizerEscaping($patterns)); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Handler/StringHandlerTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/StringHandlerTest.php new file mode 100644 index 000000000000..32ce59a624ff --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/StringHandlerTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Tests\Handler; + +use Symfony\Component\CssSelector\Parser\Handler\StringHandler; +use Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; + +class StringHandlerTest extends AbstractHandlerTest +{ + public function getHandleValueTestData() + { + return array( + array('"hello"', new Token(Token::TYPE_STRING, 'hello', 1), ''), + array('"1"', new Token(Token::TYPE_STRING, '1', 1), ''), + array('" "', new Token(Token::TYPE_STRING, ' ', 1), ''), + array('""', new Token(Token::TYPE_STRING, '', 1), ''), + array("'hello'", new Token(Token::TYPE_STRING, 'hello', 1), ''), + + array("'foo'bar", new Token(Token::TYPE_STRING, 'foo', 1), 'bar'), + ); + } + + public function getDontHandleValueTestData() + { + return array( + array('hello'), + array('>'), + array('1'), + array(' '), + ); + } + + protected function generateHandler() + { + $patterns = new TokenizerPatterns(); + + return new StringHandler($patterns, new TokenizerEscaping($patterns)); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Handler/WhitespaceHandlerTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/WhitespaceHandlerTest.php new file mode 100644 index 000000000000..0d9140486db6 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Handler/WhitespaceHandlerTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Tests\Handler; + +use Symfony\Component\CssSelector\Parser\Handler\WhitespaceHandler; +use Symfony\Component\CssSelector\Parser\Token; + +class WhitespaceHandlerTest extends AbstractHandlerTest +{ + public function getHandleValueTestData() + { + return array( + array(' ', new Token(Token::TYPE_WHITESPACE, ' ', 0), ''), + array("\n", new Token(Token::TYPE_WHITESPACE, "\n", 0), ''), + array("\t", new Token(Token::TYPE_WHITESPACE, "\t", 0), ''), + + array(' foo', new Token(Token::TYPE_WHITESPACE, ' ', 0), 'foo'), + array(' .foo', new Token(Token::TYPE_WHITESPACE, ' ', 0), '.foo'), + ); + } + + public function getDontHandleValueTestData() + { + return array( + array('>'), + array('1'), + array('a'), + ); + } + + protected function generateHandler() + { + return new WhitespaceHandler(); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php new file mode 100644 index 000000000000..d94bbbcb7aa2 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php @@ -0,0 +1,247 @@ + + * + * 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 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 \PHPUnit_Framework_TestCase +{ + /** @dataProvider getParserTestData */ + public function testParser($source, $representation) + { + $parser = new Parser(); + + $this->assertEquals($representation, array_map(function (SelectorNode $node) { + return (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->assertEquals(1, count($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->assertEquals(1, count($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->assertEquals(1, count($selectors)); + + /** @var FunctionNode $function */ + $function = $selectors[0]->getTree(); + $this->assertEquals(array($a, $b), Parser::parseSeries($function->getArguments())); + } + + /** @dataProvider getParseSeriesExceptionTestData */ + public function testParseSeriesException($series) + { + $parser = new Parser(); + $selectors = $parser->parse(sprintf(':nth-child(%s)', $series)); + $this->assertEquals(1, count($selectors)); + + /** @var FunctionNode $function */ + $function = $selectors[0]->getTree(); + $this->setExpectedException('Symfony\Component\CssSelector\Exception\SyntaxErrorException'); + Parser::parseSeries($function->getArguments()); + } + + public function getParserTestData() + { + return array( + array('*', array('Element[*]')), + array('*|*', array('Element[*]')), + array('*|foo', array('Element[foo]')), + array('foo|*', array('Element[foo|*]')), + array('foo|bar', array('Element[foo|bar]')), + array('#foo#bar', array('Hash[Hash[Element[*]#foo]#bar]')), + array('div>.foo', array('CombinedSelector[Element[div] > Class[Element[*].foo]]')), + array('div> .foo', array('CombinedSelector[Element[div] > Class[Element[*].foo]]')), + array('div >.foo', array('CombinedSelector[Element[div] > Class[Element[*].foo]]')), + array('div > .foo', array('CombinedSelector[Element[div] > Class[Element[*].foo]]')), + array("div \n> \t \t .foo", array('CombinedSelector[Element[div] > Class[Element[*].foo]]')), + array('td.foo,.bar', array('Class[Element[td].foo]', 'Class[Element[*].bar]')), + array('td.foo, .bar', array('Class[Element[td].foo]', 'Class[Element[*].bar]')), + array("td.foo\t\r\n\f ,\t\r\n\f .bar", array('Class[Element[td].foo]', 'Class[Element[*].bar]')), + array('td.foo,.bar', array('Class[Element[td].foo]', 'Class[Element[*].bar]')), + array('td.foo, .bar', array('Class[Element[td].foo]', 'Class[Element[*].bar]')), + array("td.foo\t\r\n\f ,\t\r\n\f .bar", array('Class[Element[td].foo]', 'Class[Element[*].bar]')), + array('div, td.foo, div.bar span', array('Element[div]', 'Class[Element[td].foo]', 'CombinedSelector[Class[Element[div].bar] Element[span]]')), + array('div > p', array('CombinedSelector[Element[div] > Element[p]]')), + array('td:first', array('Pseudo[Element[td]:first]')), + array('td :first', array('CombinedSelector[Element[td] Pseudo[Element[*]:first]]')), + array('a[name]', array('Attribute[Element[a][name]]')), + array("a[ name\t]", array('Attribute[Element[a][name]]')), + array('a [name]', array('CombinedSelector[Element[a] Attribute[Element[*][name]]]')), + array('a[rel="include"]', array("Attribute[Element[a][rel = 'include']]")), + array('a[rel = include]', array("Attribute[Element[a][rel = 'include']]")), + array("a[hreflang |= 'en']", array("Attribute[Element[a][hreflang |= 'en']]")), + array('a[hreflang|=en]', array("Attribute[Element[a][hreflang |= 'en']]")), + array('div:nth-child(10)', array("Function[Element[div]:nth-child(['10'])]")), + array(':nth-child(2n+2)', array("Function[Element[*]:nth-child(['2', 'n', '+2'])]")), + array('div:nth-of-type(10)', array("Function[Element[div]:nth-of-type(['10'])]")), + array('div div:nth-of-type(10) .aclass', array("CombinedSelector[CombinedSelector[Element[div] Function[Element[div]:nth-of-type(['10'])]] Class[Element[*].aclass]]")), + array('label:only', array('Pseudo[Element[label]:only]')), + array('a:lang(fr)', array("Function[Element[a]:lang(['fr'])]")), + array('div:contains("foo")', array("Function[Element[div]:contains(['foo'])]")), + array('div#foobar', array('Hash[Element[div]#foobar]')), + array('div:not(div.foo)', array('Negation[Element[div]:not(Class[Element[div].foo])]')), + array('td ~ th', array('CombinedSelector[Element[td] ~ Element[th]]')), + ); + } + + public function getParserExceptionTestData() + { + return array( + array('attributes(href)/html/body/a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '(', 10))->getMessage()), + array('attributes(href)', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '(', 10))->getMessage()), + array('html/body/a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '/', 4))->getMessage()), + array(' ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 1))->getMessage()), + array('div, ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 5))->getMessage()), + array(' , div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, ',', 1))->getMessage()), + array('p, , div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, ',', 3))->getMessage()), + array('div > ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 6))->getMessage()), + array(' > div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '>', 2))->getMessage()), + array('foo|#bar', SyntaxErrorException::unexpectedToken('identifier or "*"', new Token(Token::TYPE_HASH, 'bar', 4))->getMessage()), + array('#.foo', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '#', 0))->getMessage()), + array('.#foo', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_HASH, 'foo', 1))->getMessage()), + array(':#foo', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_HASH, 'foo', 1))->getMessage()), + array('[*]', SyntaxErrorException::unexpectedToken('"|"', new Token(Token::TYPE_DELIMITER, ']', 2))->getMessage()), + array('[foo|]', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_DELIMITER, ']', 5))->getMessage()), + array('[#]', SyntaxErrorException::unexpectedToken('identifier or "*"', new Token(Token::TYPE_DELIMITER, '#', 1))->getMessage()), + array('[foo=#]', SyntaxErrorException::unexpectedToken('string or identifier', new Token(Token::TYPE_DELIMITER, '#', 5))->getMessage()), + array(':nth-child()', SyntaxErrorException::unexpectedToken('at least one argument', new Token(Token::TYPE_DELIMITER, ')', 11))->getMessage()), + array('[href]a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_IDENTIFIER, 'a', 6))->getMessage()), + array('[rel:stylesheet]', SyntaxErrorException::unexpectedToken('operator', new Token(Token::TYPE_DELIMITER, ':', 4))->getMessage()), + array('[rel=stylesheet', SyntaxErrorException::unexpectedToken('"]"', new Token(Token::TYPE_FILE_END, '', 15))->getMessage()), + array(':lang(fr', SyntaxErrorException::unexpectedToken('an argument', new Token(Token::TYPE_FILE_END, '', 8))->getMessage()), + array(':contains("foo', SyntaxErrorException::unclosedString(10)->getMessage()), + array('foo!', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '!', 3))->getMessage()), + ); + } + + public function getPseudoElementsTestData() + { + return array( + array('foo', 'Element[foo]', ''), + array('*', 'Element[*]', ''), + array(':empty', 'Pseudo[Element[*]:empty]', ''), + array(':BEfore', 'Element[*]', 'before'), + array(':aftER', 'Element[*]', 'after'), + array(':First-Line', 'Element[*]', 'first-line'), + array(':First-Letter', 'Element[*]', 'first-letter'), + array('::befoRE', 'Element[*]', 'before'), + array('::AFter', 'Element[*]', 'after'), + array('::firsT-linE', 'Element[*]', 'first-line'), + array('::firsT-letteR', 'Element[*]', 'first-letter'), + array('::Selection', 'Element[*]', 'selection'), + array('foo:after', 'Element[foo]', 'after'), + array('foo::selection', 'Element[foo]', 'selection'), + array('lorem#ipsum ~ a#b.c[href]:empty::selection', 'CombinedSelector[Hash[Element[lorem]#ipsum] ~ Pseudo[Attribute[Class[Hash[Element[a]#b].c][href]]:empty]]', 'selection'), + ); + } + + public function getSpecificityTestData() + { + return array( + array('*', 0), + array(' foo', 1), + array(':empty ', 10), + array(':before', 1), + array('*:before', 1), + array(':nth-child(2)', 10), + array('.bar', 10), + array('[baz]', 10), + array('[baz="4"]', 10), + array('[baz^="4"]', 10), + array('#lipsum', 100), + array(':not(*)', 0), + array(':not(foo)', 1), + array(':not(.foo)', 10), + array(':not([foo])', 10), + array(':not(:empty)', 10), + array(':not(#foo)', 100), + array('foo:empty', 11), + array('foo:before', 2), + array('foo::before', 2), + array('foo:empty::before', 12), + array('#lorem + foo#ipsum:first-child > bar:first-line', 213), + ); + } + + public function getParseSeriesTestData() + { + return array( + array('1n+3', 1, 3), + array('1n +3', 1, 3), + array('1n + 3', 1, 3), + array('1n+ 3', 1, 3), + array('1n-3', 1, -3), + array('1n -3', 1, -3), + array('1n - 3', 1, -3), + array('1n- 3', 1, -3), + array('n-5', 1, -5), + array('odd', 2, 1), + array('even', 2, 0), + array('3n', 3, 0), + array('n', 1, 0), + array('+n', 1, 0), + array('-n', -1, 0), + array('5', 0, 5), + ); + } + + public function getParseSeriesExceptionTestData() + { + return array( + array('foo'), + array('n+'), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/ReaderTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/ReaderTest.php new file mode 100644 index 000000000000..03c054eaaeeb --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/ReaderTest.php @@ -0,0 +1,101 @@ + + * + * 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 Symfony\Component\CssSelector\Parser\Reader; + +class ReaderTest extends \PHPUnit_Framework_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(array('hello', 'h'), $reader->findPattern('/^([a-z]).*/')); + + $this->assignPosition($reader, 2); + $this->assertFalse($reader->findPattern('/^h.*/')); + $this->assertEquals(array('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, $value) + { + $position = new \ReflectionProperty($reader, 'position'); + $position->setAccessible(true); + $position->setValue($reader, $value); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/ClassParserTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/ClassParserTest.php new file mode 100644 index 000000000000..d04cad4138c5 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/ClassParserTest.php @@ -0,0 +1,40 @@ + + * + * 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 Symfony\Component\CssSelector\Node\SelectorNode; +use Symfony\Component\CssSelector\Parser\Shortcut\ClassParser; + +/** + * @author Jean-François Simon + */ +class ClassParserTest extends \PHPUnit_Framework_TestCase +{ + /** @dataProvider getParseTestData */ + public function testParse($source, $representation) + { + $parser = new ClassParser(); + $selectors = $parser->parse($source); + $this->assertEquals(1, count($selectors)); + + /** @var SelectorNode $selector */ + $selector = $selectors[0]; + $this->assertEquals($representation, (string) $selector->getTree()); + } + + public function getParseTestData() + { + return array( + array('.class', 'Class[Element[*].class]'), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/ElementParserTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/ElementParserTest.php new file mode 100644 index 000000000000..107cac940c5a --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/ElementParserTest.php @@ -0,0 +1,42 @@ + + * + * 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 Symfony\Component\CssSelector\Node\SelectorNode; +use Symfony\Component\CssSelector\Parser\Shortcut\ElementParser; + +/** + * @author Jean-François Simon + */ +class ElementParserTest extends \PHPUnit_Framework_TestCase +{ + /** @dataProvider getParseTestData */ + public function testParse($source, $representation) + { + $parser = new ElementParser(); + $selectors = $parser->parse($source); + $this->assertEquals(1, count($selectors)); + + /** @var SelectorNode $selector */ + $selector = $selectors[0]; + $this->assertEquals($representation, (string) $selector->getTree()); + } + + public function getParseTestData() + { + return array( + array('p', 'Element[p]'), + array('*', 'Element[*]'), + array('h1', 'Element[h1]'), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/HashParserTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/HashParserTest.php new file mode 100644 index 000000000000..d9fee19dd779 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/Shortcut/HashParserTest.php @@ -0,0 +1,41 @@ + + * + * 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 Symfony\Component\CssSelector\Node\SelectorNode; +use Symfony\Component\CssSelector\Parser\Shortcut\HashParser; + +/** + * @author Jean-François Simon + */ +class HashParserTest extends \PHPUnit_Framework_TestCase +{ + /** @dataProvider getParseTestData */ + public function testParse($source, $representation) + { + $parser = new HashParser(); + $selectors = $parser->parse($source); + $this->assertEquals(1, count($selectors)); + + /** @var SelectorNode $selector */ + $selector = $selectors[0]; + $this->assertEquals($representation, (string) $selector->getTree()); + } + + public function getParseTestData() + { + return array( + array('#id', 'Hash[Element[*]#id]'), + array('h1#main', 'Hash[Element[h1]#main]'), + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/TokenStreamTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/TokenStreamTest.php new file mode 100644 index 000000000000..8f3253a7d59f --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Parser/TokenStreamTest.php @@ -0,0 +1,95 @@ + + * + * 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 Symfony\Component\CssSelector\Parser\Token; +use Symfony\Component\CssSelector\Parser\TokenStream; + +class TokenStreamTest extends \PHPUnit_Framework_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() + { + $this->setExpectedException('Symfony\Component\CssSelector\Exception\SyntaxErrorException'); + + $stream = new TokenStream(); + $stream->push(new Token(Token::TYPE_DELIMITER, '.', 2)); + $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() + { + $this->setExpectedException('Symfony\Component\CssSelector\Exception\SyntaxErrorException'); + + $stream = new TokenStream(); + $stream->push(new Token(Token::TYPE_DELIMITER, '.', 2)); + $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()); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/TokenizerTest.php b/src/Symfony/Component/CssSelector/Tests/TokenizerTest.php deleted file mode 100644 index e0e00d8ed324..000000000000 --- a/src/Symfony/Component/CssSelector/Tests/TokenizerTest.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * 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 Symfony\Component\CssSelector\Tokenizer; - -class TokenizerTest extends \PHPUnit_Framework_TestCase -{ - protected $tokenizer; - - protected function setUp() - { - $this->tokenizer = new Tokenizer(); - } - - /** - * @dataProvider getCssSelectors - */ - public function testTokenize($css) - { - $this->assertEquals($css, $this->tokensToString($this->tokenizer->tokenize($css)), '->tokenize() lexes an input string and returns an array of tokens'); - } - - public function testTokenizeWithQuotedStrings() - { - $this->assertEquals('foo[class=foo bar ]', $this->tokensToString($this->tokenizer->tokenize('foo[class="foo bar"]')), '->tokenize() lexes an input string and returns an array of tokens'); - $this->assertEquals("foo[class=foo Abar ]", $this->tokensToString($this->tokenizer->tokenize('foo[class="foo \\65 bar"]')), '->tokenize() lexes an input string and returns an array of tokens'); - $this->assertEquals("img[alt= ]", $this->tokensToString($this->tokenizer->tokenize('img[alt=""]')), '->tokenize() lexes an input string and returns an array of tokens'); - } - - /** - * @expectedException \Symfony\Component\CssSelector\Exception\ParseException - */ - public function testTokenizeInvalidString() - { - $this->tokensToString($this->tokenizer->tokenize('/invalid')); - } - - public function getCssSelectors() - { - return array( - array('h1'), - array('h1:nth-child(3n+1)'), - array('h1 > p'), - array('h1#foo'), - array('h1.foo'), - array('h1[class*=foo]'), - array('h1 .foo'), - array('h1 #foo'), - array('h1 [class*=foo]'), - ); - } - - protected function tokensToString($tokens) - { - $str = ''; - foreach ($tokens as $token) { - $str .= str_repeat(' ', $token->getPosition() - strlen($str)).$token; - } - - return $str; - } -} diff --git a/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/ids.html b/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/ids.html new file mode 100644 index 000000000000..5799fad25ecf --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/ids.html @@ -0,0 +1,48 @@ + + + + +
+ + + + link +
    +
  1. content
  2. +
  3. +
    +
    +
  4. +
  5. +
  6. +
  7. +
  8. +
  9. +
+

+ hi there + guy + + + + + + + +

+ + +
+

+
    +
+ + + + +
+
+ diff --git a/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/lang.xml b/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/lang.xml new file mode 100644 index 000000000000..14f8dbed681f --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/lang.xml @@ -0,0 +1,11 @@ + + a + b + c + d + e + f + + + + diff --git a/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/shakespear.html b/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/shakespear.html new file mode 100644 index 000000000000..15d1ad33a319 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/XPath/Fixtures/shakespear.html @@ -0,0 +1,308 @@ + + + + + + +
+
+

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
+
+
+
+
+ + diff --git a/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php b/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php new file mode 100644 index 000000000000..af915e30f9f8 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php @@ -0,0 +1,308 @@ + + * + * 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 Symfony\Component\CssSelector\XPath\Extension\HtmlExtension; +use Symfony\Component\CssSelector\XPath\Translator; + +class TranslatorTest extends \PHPUnit_Framework_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 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->assertEquals(count($elementsId), count($elements)); + foreach ($elements as $element) { + $this->assertTrue(in_array($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; + 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), $elementsId); + foreach ($elements as $element) { + if (null !== $element->attributes()->id) { + $this->assertTrue(in_array($element->attributes()->id, $elementsId)); + } + } + } + + /** @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->assertEquals($count, count($elements)); + } + + public function getXpathLiteralTestData() + { + return array( + array('foo', "'foo'"), + array("foo's bar", '"foo\'s bar"'), + array("foo's \"middle\" bar", 'concat(\'foo\', "\'", \'s "middle" bar\')'), + array("foo's 'middle' \"bar\"", 'concat(\'foo\', "\'", \'s \', "\'", \'middle\', "\'", \' "bar"\')'), + ); + } + + public function getCssToXPathTestData() + { + return array( + array('*', "*"), + array('e', "e"), + array('*|e', "e"), + array('e|f', "e:f"), + array('e[foo]', "e[@foo]"), + array('e[foo|bar]', "e[@foo:bar]"), + array('e[foo="bar"]', "e[@foo = 'bar']"), + array('e[foo~="bar"]', "e[@foo and contains(concat(' ', normalize-space(@foo), ' '), ' bar ')]"), + array('e[foo^="bar"]', "e[@foo and starts-with(@foo, 'bar')]"), + array('e[foo$="bar"]', "e[@foo and substring(@foo, string-length(@foo)-2) = 'bar']"), + array('e[foo*="bar"]', "e[@foo and contains(@foo, 'bar')]"), + array('e[hreflang|="en"]', "e[@hreflang and (@hreflang = 'en' or starts-with(@hreflang, 'en-'))]"), + array('e:nth-child(1)', "*/*[name() = 'e' and (position() = 1)]"), + array('e:nth-last-child(1)', "*/*[name() = 'e' and (position() = last() - 1)]"), + array('e:nth-last-child(2n+2)', "*/*[name() = 'e' and ((position() +2) mod -2 = 0 and position() < (last() -2))]"), + array('e:nth-of-type(1)', "*/e[position() = 1]"), + array('e:nth-last-of-type(1)', "*/e[position() = last() - 1]"), + array('e:nth-last-of-type(1)', "*/e[position() = last() - 1]"), + array('div e:nth-last-of-type(1) .aclass', "div/descendant-or-self::*/e[position() = last() - 1]/descendant-or-self::*/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' aclass ')]"), + array('e:first-child', "*/*[name() = 'e' and (position() = 1)]"), + array('e:last-child', "*/*[name() = 'e' and (position() = last())]"), + array('e:first-of-type', "*/e[position() = 1]"), + array('e:last-of-type', "*/e[position() = last()]"), + array('e:only-child', "*/*[name() = 'e' and (last() = 1)]"), + array('e:only-of-type', "e[last() = 1]"), + array('e:empty', "e[not(*) and not(string-length())]"), + array('e:EmPTY', "e[not(*) and not(string-length())]"), + array('e:root', "e[not(parent::*)]"), + array('e:hover', "e[0]"), + array('e:contains("foo")', "e[contains(string(.), 'foo')]"), + array('e:ConTains(foo)', "e[contains(string(.), 'foo')]"), + array('e.warning', "e[@class and contains(concat(' ', normalize-space(@class), ' '), ' warning ')]"), + array('e#myid', "e[@id = 'myid']"), + array('e:not(:nth-child(odd))', "e[not((position() -1) mod 2 = 0 and position() >= 1)]"), + array('e:nOT(*)', "e[0]"), + array('e f', "e/descendant-or-self::*/f"), + array('e > f', "e/f"), + array('e + f', "e/following-sibling::*[name() = 'f' and (position() = 1)]"), + array('e ~ f', "e/following-sibling::f"), + array('div#container p', "div[@id = 'container']/descendant-or-self::*/p"), + ); + } + + public function getXmlLangTestData() + { + return array( + array(':lang("EN")', array('first', 'second', 'third', 'fourth')), + array(':lang("en-us")', array('second', 'fourth')), + array(':lang(en-nz)', array('third')), + array(':lang(fr)', array('fifth')), + array(':lang(ru)', array('sixth')), + array(":lang('ZH')", array('eighth')), + array(':lang(de) :lang(zh)', array('eighth')), + array(':lang(en), :lang(zh)', array('first', 'second', 'third', 'fourth', 'eighth')), + array(':lang(es)', array()), + ); + } + + public function getHtmlIdsTestData() + { + return array( + array('div', array('outer-div', 'li-div', 'foobar-div')), + array('DIV', array('outer-div', 'li-div', 'foobar-div')), // case-insensitive in HTML + array('div div', array('li-div')), + array('div, div div', array('outer-div', 'li-div', 'foobar-div')), + array('a[name]', array('name-anchor')), + array('a[NAme]', array('name-anchor')), // case-insensitive in HTML: + array('a[rel]', array('tag-anchor', 'nofollow-anchor')), + array('a[rel="tag"]', array('tag-anchor')), + array('a[href*="localhost"]', array('tag-anchor')), + array('a[href*=""]', array()), + array('a[href^="http"]', array('tag-anchor', 'nofollow-anchor')), + array('a[href^="http:"]', array('tag-anchor')), + array('a[href^=""]', array()), + array('a[href$="org"]', array('nofollow-anchor')), + array('a[href$=""]', array()), + array('div[foobar~="bc"]', array('foobar-div')), + array('div[foobar~="cde"]', array('foobar-div')), + array('[foobar~="ab bc"]', array('foobar-div')), + array('[foobar~=""]', array()), + array('[foobar~=" \t"]', array()), + array('div[foobar~="cd"]', array()), + array('*[lang|="En"]', array('second-li')), + array('[lang|="En-us"]', array('second-li')), + // Attribute values are case sensitive + array('*[lang|="en"]', array()), + array('[lang|="en-US"]', array()), + array('*[lang|="e"]', array()), + // ... :lang() is not. + array(':lang("EN")', array('second-li', 'li-div')), + array('*:lang(en-US)', array('second-li', 'li-div')), + array(':lang("e")', array()), + array('li:nth-child(3)', array('third-li')), + array('li:nth-child(10)', array()), + array('li:nth-child(2n)', array('second-li', 'fourth-li', 'sixth-li')), + array('li:nth-child(even)', array('second-li', 'fourth-li', 'sixth-li')), + array('li:nth-child(2n+0)', array('second-li', 'fourth-li', 'sixth-li')), + array('li:nth-child(+2n+1)', array('first-li', 'third-li', 'fifth-li', 'seventh-li')), + array('li:nth-child(odd)', array('first-li', 'third-li', 'fifth-li', 'seventh-li')), + array('li:nth-child(2n+4)', array('fourth-li', 'sixth-li')), + // FIXME: I'm not 100% sure this is right: + array('li:nth-child(3n+1)', array('first-li', 'fourth-li', 'seventh-li')), + array('li:nth-last-child(0)', array('seventh-li')), + array('li:nth-last-child(2n)', array('second-li', 'fourth-li', 'sixth-li')), + array('li:nth-last-child(even)', array('second-li', 'fourth-li', 'sixth-li')), + array('li:nth-last-child(2n+2)', array('second-li', 'fourth-li')), + array('ol:first-of-type', array('first-ol')), + array('ol:nth-child(1)', array()), + array('ol:nth-of-type(2)', array('second-ol')), + // FIXME: like above (1) or (2)? + array('ol:nth-last-of-type(1)', array('first-ol')), + array('span:only-child', array('foobar-span')), + array('li div:only-child', array('li-div')), + array('div *:only-child', array('li-div', 'foobar-span')), + array('p:only-of-type', array('paragraph')), + array('a:empty', array('name-anchor')), + array('a:EMpty', array('name-anchor')), + array('li:empty', array('third-li', 'fourth-li', 'fifth-li', 'sixth-li')), + array(':root', array('html')), + array('html:root', array('html')), + array('li:root', array()), + array('* :root', array()), + array('*:contains("link")', array('html', 'outer-div', 'tag-anchor', 'nofollow-anchor')), + array(':CONtains("link")', array('html', 'outer-div', 'tag-anchor', 'nofollow-anchor')), + array('*:contains("LInk")', array()), // case sensitive + array('*:contains("e")', array('html', 'nil', 'outer-div', 'first-ol', 'first-li', 'paragraph', 'p-em')), + array('*:contains("E")', array()), // case-sensitive + array('.a', array('first-ol')), + array('.b', array('first-ol')), + array('*.a', array('first-ol')), + array('ol.a', array('first-ol')), + array('.c', array('first-ol', 'third-li', 'fourth-li')), + array('*.c', array('first-ol', 'third-li', 'fourth-li')), + array('ol *.c', array('third-li', 'fourth-li')), + array('ol li.c', array('third-li', 'fourth-li')), + array('li ~ li.c', array('third-li', 'fourth-li')), + array('ol > li.c', array('third-li', 'fourth-li')), + array('#first-li', array('first-li')), + array('li#first-li', array('first-li')), + array('*#first-li', array('first-li')), + array('li div', array('li-div')), + array('li > div', array('li-div')), + array('div div', array('li-div')), + array('div > div', array()), + array('div>.c', array('first-ol')), + array('div > .c', array('first-ol')), + array('div + div', array('foobar-div')), + array('a ~ a', array('tag-anchor', 'nofollow-anchor')), + array('a[rel="tag"] ~ a', array('nofollow-anchor')), + array('ol#first-ol li:last-child', array('seventh-li')), + array('ol#first-ol *:last-child', array('li-div', 'seventh-li')), + array('#outer-div:first-child', array('outer-div')), + array('#outer-div :first-child', array('name-anchor', 'first-li', 'li-div', 'p-b', 'checkbox-fieldset-disabled', 'area-href')), + array('a[href]', array('tag-anchor', 'nofollow-anchor')), + array(':not(*)', array()), + array('a:not([href])', array('name-anchor')), + array('ol :Not(li[class])', array('first-li', 'second-li', 'li-div', 'fifth-li', 'sixth-li', 'seventh-li')), + // HTML-specific + array(':link', array('link-href', 'tag-anchor', 'nofollow-anchor', 'area-href')), + array(':visited', array()), + array(':enabled', array('link-href', 'tag-anchor', 'nofollow-anchor', 'checkbox-unchecked', 'text-checked', 'checkbox-checked', 'area-href')), + array(':disabled', array('checkbox-disabled', 'checkbox-disabled-checked', 'fieldset', 'checkbox-fieldset-disabled')), + array(':checked', array('checkbox-checked', 'checkbox-disabled-checked')), + ); + } + + public function getHtmlShakespearTestData() + { + return array( + array('*', 246), + array('div:contains(CELIA)', 26), + array('div:only-child', 22), // ? + array('div:nth-child(even)', 106), + array('div:nth-child(2n)', 106), + array('div:nth-child(odd)', 137), + array('div:nth-child(2n+1)', 137), + array('div:nth-child(n)', 243), + array('div:last-child', 53), + array('div:first-child', 51), + array('div > div', 242), + array('div + div', 190), + array('div ~ div', 190), + array('body', 1), + array('body div', 243), + array('div', 243), + array('div div', 242), + array('div div div', 241), + array('div, div, div', 243), + array('div, a, span', 243), + array('.dialog', 51), + array('div.dialog', 51), + array('div .dialog', 51), + array('div.character, div.dialog', 99), + array('div.direction.dialog', 0), + array('div.dialog.direction', 0), + array('div.dialog.scene', 1), + array('div.scene.scene', 1), + array('div.scene .scene', 0), + array('div.direction .dialog ', 0), + array('div .dialog .direction', 4), + array('div.dialog .dialog .direction', 4), + array('#speech5', 1), + array('div#speech5', 1), + array('div #speech5', 1), + array('div.scene div.dialog', 49), + array('div#scene1 div.dialog div', 142), + array('#scene1 #speech1', 1), + array('div[class]', 103), + array('div[class=dialog]', 50), + array('div[class^=dia]', 51), + array('div[class$=log]', 50), + array('div[class*=sce]', 1), + array('div[class|=dialog]', 50), // ? Seems right + array('div[class!=madeup]', 243), // ? Seems right + array('div[class~=dialog]', 51), // ? Seems right + ); + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/XPathExprTest.php b/src/Symfony/Component/CssSelector/Tests/XPathExprTest.php deleted file mode 100644 index df6e5a0a03d4..000000000000 --- a/src/Symfony/Component/CssSelector/Tests/XPathExprTest.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * 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 Symfony\Component\CssSelector\XPathExpr; - -class XPathExprTest extends \PHPUnit_Framework_TestCase -{ - /** - * @dataProvider getXPathLiteralValues - */ - public function testXpathLiteral($value, $literal) - { - $this->assertEquals($literal, XPathExpr::xpathLiteral($value)); - } - - public function getXPathLiteralValues() - { - return array( - array('foo', "'foo'"), - array("foo's bar", '"foo\'s bar"'), - array("foo's \"middle\" bar", 'concat(\'foo\', "\'", \'s "middle" bar\')'), - array("foo's 'middle' \"bar\"", 'concat(\'foo\', "\'", \'s \', "\'", \'middle\', "\'", \' "bar"\')'), - ); - } -} diff --git a/src/Symfony/Component/CssSelector/Tests/bootstrap.php b/src/Symfony/Component/CssSelector/Tests/bootstrap.php new file mode 100644 index 000000000000..2f05eb84eb12 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/bootstrap.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +spl_autoload_register(function ($class) { + if (0 === strpos(ltrim($class, '/'), 'Symfony\Component\CssSelector')) { + if (file_exists($file = __DIR__.'/../'.substr(str_replace('\\', '/', $class), strlen('Symfony\Component\CssSelector')).'.php')) { + require_once $file; + } + } +}); + +if (file_exists($loader = __DIR__.'/../vendor/autoload.php')) { + require_once $loader; +} diff --git a/src/Symfony/Component/CssSelector/Token.php b/src/Symfony/Component/CssSelector/Token.php deleted file mode 100644 index 6748a44fd85e..000000000000 --- a/src/Symfony/Component/CssSelector/Token.php +++ /dev/null @@ -1,73 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\CssSelector; - -/** - * Token represents a CSS Selector token. - * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. - * - * @author Fabien Potencier - */ -class Token -{ - private $type; - private $value; - private $position; - - /** - * Constructor. - * - * @param string $type The type of this token. - * @param mixed $value The value of this token. - * @param integer $position The order of this token. - */ - public function __construct($type, $value, $position) - { - $this->type = $type; - $this->value = $value; - $this->position = $position; - } - - /** - * Gets a string representation of this token. - * - * @return string - */ - public function __toString() - { - return (string) $this->value; - } - - /** - * Answers whether this token's type equals to $type. - * - * @param string $type The type to test against this token's one. - * - * @return Boolean - */ - public function isType($type) - { - return $this->type == $type; - } - - /** - * Gets the position of this token. - * - * @return integer - */ - public function getPosition() - { - return $this->position; - } -} diff --git a/src/Symfony/Component/CssSelector/TokenStream.php b/src/Symfony/Component/CssSelector/TokenStream.php deleted file mode 100644 index cf04702a472a..000000000000 --- a/src/Symfony/Component/CssSelector/TokenStream.php +++ /dev/null @@ -1,105 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\CssSelector; - -/** - * TokenStream represents a stream of CSS Selector tokens. - * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. - * - * @author Fabien Potencier - */ -class TokenStream -{ - private $used; - private $tokens; - private $source; - private $peeked; - private $peeking; - - /** - * Constructor. - * - * @param array $tokens The tokens that make the stream. - * @param mixed $source The source of the stream. - */ - public function __construct($tokens, $source = null) - { - $this->used = array(); - $this->tokens = $tokens; - $this->source = $source; - $this->peeked = null; - $this->peeking = false; - } - - /** - * Gets the tokens that have already been visited in this stream. - * - * @return array - */ - public function getUsed() - { - return $this->used; - } - - /** - * Gets the next token in the stream or null if there is none. - * Note that if this stream was set to be peeking its behavior - * will be restored to not peeking after this operation. - * - * @return mixed - */ - public function next() - { - if ($this->peeking) { - $this->peeking = false; - $this->used[] = $this->peeked; - - return $this->peeked; - } - - if (!count($this->tokens)) { - return null; - } - - $next = array_shift($this->tokens); - $this->used[] = $next; - - return $next; - } - - /** - * Peeks for the next token in this stream. This means that the next token - * will be returned but it won't be considered as used (visited) until the - * next() method is invoked. - * If there are no remaining tokens null will be returned. - * - * @see next() - * - * @return mixed - */ - public function peek() - { - if (!$this->peeking) { - if (!count($this->tokens)) { - return null; - } - - $this->peeked = array_shift($this->tokens); - - $this->peeking = true; - } - - return $this->peeked; - } -} diff --git a/src/Symfony/Component/CssSelector/Tokenizer.php b/src/Symfony/Component/CssSelector/Tokenizer.php deleted file mode 100644 index 34e8ac580a02..000000000000 --- a/src/Symfony/Component/CssSelector/Tokenizer.php +++ /dev/null @@ -1,201 +0,0 @@ - - * - * 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\Exception\ParseException; - -/** - * Tokenizer lexes a CSS Selector to tokens. - * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. - * - * @author Fabien Potencier - */ -class Tokenizer -{ - /** - * Takes a CSS selector and returns an array holding the Tokens - * it contains. - * - * @param string $s The selector to lex. - * - * @return array Token[] - */ - public function tokenize($s) - { - if (function_exists('mb_internal_encoding') && ((int) ini_get('mbstring.func_overload')) & 2) { - $mbEncoding = mb_internal_encoding(); - mb_internal_encoding('ASCII'); - } - - $tokens = array(); - $pos = 0; - $s = preg_replace('#/\*.*?\*/#s', '', $s); - - while (true) { - if (preg_match('#\s+#A', $s, $match, 0, $pos)) { - $precedingWhitespacePos = $pos; - $pos += strlen($match[0]); - } else { - $precedingWhitespacePos = 0; - } - - if ($pos >= strlen($s)) { - if (isset($mbEncoding)) { - mb_internal_encoding($mbEncoding); - } - - return $tokens; - } - - if (preg_match('#[+-]?\d*n(?:[+-]\d+)?#A', $s, $match, 0, $pos) && 'n' !== $match[0]) { - $sym = substr($s, $pos, strlen($match[0])); - $tokens[] = new Token('Symbol', $sym, $pos); - $pos += strlen($match[0]); - - continue; - } - - $c = $s[$pos]; - $c2 = substr($s, $pos, 2); - if (in_array($c2, array('~=', '|=', '^=', '$=', '*=', '::', '!='))) { - $tokens[] = new Token('Token', $c2, $pos); - $pos += 2; - - continue; - } - - if (in_array($c, array('>', '+', '~', ',', '.', '*', '=', '[', ']', '(', ')', '|', ':', '#'))) { - if (in_array($c, array('.', '#', '[')) && $precedingWhitespacePos > 0) { - $tokens[] = new Token('Token', ' ', $precedingWhitespacePos); - } - $tokens[] = new Token('Token', $c, $pos); - ++$pos; - - continue; - } - - if ('"' === $c || "'" === $c) { - // Quoted string - $oldPos = $pos; - list($sym, $pos) = $this->tokenizeEscapedString($s, $pos); - - $tokens[] = new Token('String', $sym, $oldPos); - - continue; - } - - $oldPos = $pos; - list($sym, $pos) = $this->tokenizeSymbol($s, $pos); - - $tokens[] = new Token('Symbol', $sym, $oldPos); - - continue; - } - } - - /** - * Tokenizes a quoted string (i.e. 'A string quoted with \' characters'), - * and returns an array holding the unquoted string contained by $s and - * the new position from which tokenizing should take over. - * - * @param string $s The selector string containing the quoted string. - * @param integer $pos The starting position for the quoted string. - * - * @return array - * - * @throws ParseException When expected closing is not found - */ - private function tokenizeEscapedString($s, $pos) - { - $quote = $s[$pos]; - - $pos = $pos + 1; - $start = $pos; - while (true) { - $next = strpos($s, $quote, $pos); - if (false === $next) { - throw new ParseException(sprintf('Expected closing %s for string in: %s', $quote, substr($s, $start))); - } - - $result = substr($s, $start, $next - $start); - if (strlen($result) > 0 && '\\' === $result[strlen($result) - 1]) { - // next quote character is escaped - $pos = $next + 1; - continue; - } - - if (false !== strpos($result, '\\')) { - $result = $this->unescapeStringLiteral($result); - } - - return array($result, $next + 1); - } - } - - /** - * Unescapes a string literal and returns the unescaped string. - * - * @param string $literal The string literal to unescape. - * - * @return string - * - * @throws ParseException When invalid escape sequence is found - */ - private function unescapeStringLiteral($literal) - { - return preg_replace_callback('#(\\\\(?:[A-Fa-f0-9]{1,6}(?:\r\n|\s)?|[^A-Fa-f0-9]))#', function ($matches) use ($literal) { - if ($matches[0][0] == '\\' && strlen($matches[0]) > 1) { - $matches[0] = substr($matches[0], 1); - if (in_array($matches[0][0], array('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'a', 'b', 'c', 'd', 'e', 'f'))) { - return chr(trim($matches[0])); - } - } else { - throw new ParseException(sprintf('Invalid escape sequence %s in string %s', $matches[0], $literal)); - } - }, $literal); - } - - /** - * Lexes selector $s and returns an array holding the name of the symbol - * contained in it and the new position from which tokenizing should take - * over. - * - * @param string $s The selector string. - * @param integer $pos The position in $s at which the symbol starts. - * - * @return array - * - * @throws ParseException When Unexpected symbol is found - */ - private function tokenizeSymbol($s, $pos) - { - $start = $pos; - - if (!preg_match('#[^\w\-]#', $s, $match, PREG_OFFSET_CAPTURE, $pos)) { - // Goes to end of s - return array(substr($s, $start), strlen($s)); - } - - $matchStart = $match[0][1]; - - if ($matchStart == $pos) { - throw new ParseException(sprintf('Unexpected symbol: %s at %s', $s[$pos], $pos)); - } - - $result = substr($s, $start, $matchStart - $start); - $pos = $matchStart; - - return array($result, $pos); - } -} diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php new file mode 100644 index 000000000000..1b147e9ecf6e --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php @@ -0,0 +1,63 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +abstract class AbstractExtension implements ExtensionInterface +{ + /** + * {@inheritdoc} + */ + public function getNodeTranslators() + { + return array(); + } + + /** + * {@inheritdoc} + */ + public function getCombinationTranslators() + { + return array(); + } + + /** + * {@inheritdoc} + */ + public function getFunctionTranslators() + { + return array(); + } + + /** + * {@inheritdoc} + */ + public function getPseudoClassTranslators() + { + return array(); + } + + /** + * {@inheritdoc} + */ + public function getAttributeMatchingTranslators() + { + return array(); + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/AttributeMatchingExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/AttributeMatchingExtension.php new file mode 100644 index 000000000000..1b1f00f2863e --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/AttributeMatchingExtension.php @@ -0,0 +1,173 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class AttributeMatchingExtension extends AbstractExtension +{ + /** + * {@inheritdoc} + */ + public function getAttributeMatchingTranslators() + { + return array( + 'exists' => array($this, 'translateExists'), + '=' => array($this, 'translateEquals'), + '~=' => array($this, 'translateIncludes'), + '|=' => array($this, 'translateDashMatch'), + '^=' => array($this, 'translatePrefixMatch'), + '$=' => array($this, 'translateSuffixMatch'), + '*=' => array($this, 'translateSubstringMatch'), + '!=' => array($this, 'translateDifferent'), + ); + } + + /** + * @param XPathExpr $xpath + * @param string $attribute + * @param string $value + * + * @return XPathExpr + */ + public function translateExists(XPathExpr $xpath, $attribute, $value) + { + return $xpath->addCondition($attribute); + } + + /** + * @param XPathExpr $xpath + * @param string $attribute + * @param string $value + * + * @return XPathExpr + */ + public function translateEquals(XPathExpr $xpath, $attribute, $value) + { + return $xpath->addCondition(sprintf('%s = %s', $attribute, Translator::getXpathLiteral($value))); + } + + /** + * @param XPathExpr $xpath + * @param string $attribute + * @param string $value + * + * @return XPathExpr + */ + public function translateIncludes(XPathExpr $xpath, $attribute, $value) + { + return $xpath->addCondition($value ? sprintf( + '%1$s and contains(concat(\' \', normalize-space(%1$s), \' \'), %2$s)', + $attribute, + Translator::getXpathLiteral(' '.$value.' ') + ) : '0'); + } + + /** + * @param XPathExpr $xpath + * @param string $attribute + * @param string $value + * + * @return XPathExpr + */ + public function translateDashMatch(XPathExpr $xpath, $attribute, $value) + { + 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.'-') + )); + } + + /** + * @param XPathExpr $xpath + * @param string $attribute + * @param string $value + * + * @return XPathExpr + */ + public function translatePrefixMatch(XPathExpr $xpath, $attribute, $value) + { + return $xpath->addCondition($value ? sprintf( + '%1$s and starts-with(%1$s, %2$s)', + $attribute, + Translator::getXpathLiteral($value) + ) : '0'); + } + + /** + * @param XPathExpr $xpath + * @param string $attribute + * @param string $value + * + * @return XPathExpr + */ + public function translateSuffixMatch(XPathExpr $xpath, $attribute, $value) + { + 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'); + } + + /** + * @param XPathExpr $xpath + * @param string $attribute + * @param string $value + * + * @return XPathExpr + */ + public function translateSubstringMatch(XPathExpr $xpath, $attribute, $value) + { + return $xpath->addCondition($value ? sprintf( + '%1$s and contains(%1$s, %2$s)', + $attribute, + Translator::getXpathLiteral($value) + ) : '0'); + } + + /** + * @param XPathExpr $xpath + * @param string $attribute + * @param string $value + * + * @return XPathExpr + */ + public function translateDifferent(XPathExpr $xpath, $attribute, $value) + { + return $xpath->addCondition(sprintf( + $value ? 'not(%1$s) or %1$s != %2$s' : '%s != %s', + $attribute, + Translator::getXpathLiteral($value) + )); + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'attribute-matching'; + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/CombinationExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/CombinationExtension.php new file mode 100644 index 000000000000..639e92495219 --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/CombinationExtension.php @@ -0,0 +1,93 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class CombinationExtension extends AbstractExtension +{ + /** + * {@inheritdoc} + */ + public function getCombinationTranslators() + { + return array( + ' ' => array($this, 'translateDescendant'), + '>' => array($this, 'translateChild'), + '+' => array($this, 'translateDirectAdjacent'), + '~' => array($this, 'translateIndirectAdjacent'), + ); + } + + /** + * @param XPathExpr $xpath + * @param XPathExpr $combinedXpath + * + * @return XPathExpr + */ + public function translateDescendant(XPathExpr $xpath, XPathExpr $combinedXpath) + { + return $xpath->join('/descendant-or-self::*/', $combinedXpath); + } + + /** + * @param XPathExpr $xpath + * @param XPathExpr $combinedXpath + * + * @return XPathExpr + */ + public function translateChild(XPathExpr $xpath, XPathExpr $combinedXpath) + { + return $xpath->join('/', $combinedXpath); + } + + /** + * @param XPathExpr $xpath + * @param XPathExpr $combinedXpath + * + * @return XPathExpr + */ + public function translateDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath) + { + return $xpath + ->join('/following-sibling::', $combinedXpath) + ->addNameTest() + ->addCondition('position() = 1'); + } + + /** + * @param XPathExpr $xpath + * @param XPathExpr $combinedXpath + * + * @return XPathExpr + */ + public function translateIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath) + { + return $xpath->join('/following-sibling::', $combinedXpath); + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'combination'; + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php b/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php new file mode 100644 index 000000000000..65ab287770f2 --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php @@ -0,0 +1,65 @@ + + * + * 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 extension interface. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +interface ExtensionInterface +{ + /** + * Returns node translators. + * + * @return callable[] + */ + public function getNodeTranslators(); + + /** + * Returns combination translators. + * + * @return callable[] + */ + public function getCombinationTranslators(); + + /** + * Returns function translators. + * + * @return callable[] + */ + public function getFunctionTranslators(); + + /** + * Returns pseudo-class translators. + * + * @return callable[] + */ + public function getPseudoClassTranslators(); + + /** + * Returns attribute operation translators. + * + * @return callable[] + */ + public function getAttributeMatchingTranslators(); + + /** + * Returns extension name. + * + * @return string + */ + public function getName(); +} diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/FunctionExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/FunctionExtension.php new file mode 100644 index 000000000000..b6b1d95b18f7 --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/FunctionExtension.php @@ -0,0 +1,198 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class FunctionExtension extends AbstractExtension +{ + /** + * {@inheritdoc} + */ + public function getFunctionTranslators() + { + return array( + 'nth-child' => array($this, 'translateNthChild'), + 'nth-last-child' => array($this, 'translateNthLastChild'), + 'nth-of-type' => array($this, 'translateNthOfType'), + 'nth-last-of-type' => array($this, 'translateNthLastOfType'), + 'contains' => array($this, 'translateContains'), + 'lang' => array($this, 'translateLang'), + ); + } + + /** + * @param XPathExpr $xpath + * @param FunctionNode $function + * @param boolean $last + * @param boolean $addNameTest + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function translateNthChild(XPathExpr $xpath, FunctionNode $function, $last = false, $addNameTest = true) + { + try { + list($a, $b) = Parser::parseSeries($function->getArguments()); + } catch (SyntaxErrorException $e) { + throw new ExpressionErrorException('Invalid series: '.implode(', ', $function->getArguments()), 0, $e); + } + + $xpath->addStarPrefix(); + if ($addNameTest) { + $xpath->addNameTest(); + } + + if (0 === $a) { + return $xpath->addCondition('position() = '.($last ? 'last() - '.$b : $b)); + } + + if ($last) { + // todo: verify if this is right + $a = - $a; + $b = - $b; + } + + $conditions = 1 === $a + ? array() + : array(sprintf('(position() %s) mod %s = 0', $b > 0 ? (string) (- $b) : '+'.(- $b), $a)); + + if ($b >= 0) { + $conditions[] = 'position() >= '.$b; + } elseif ($last) { + $conditions[] = sprintf('position() < (last() %s)', $b); + } + + // 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 + + return empty($conditions) ? $xpath : $xpath->addCondition(implode(' and ', $conditions)); + } + + /** + * @param XPathExpr $xpath + * @param FunctionNode $function + * + * @return XPathExpr + */ + public function translateNthLastChild(XPathExpr $xpath, FunctionNode $function) + { + return $this->translateNthChild($xpath, $function, true); + } + + /** + * @param XPathExpr $xpath + * @param FunctionNode $function + * + * @return XPathExpr + */ + public function translateNthOfType(XPathExpr $xpath, FunctionNode $function) + { + return $this->translateNthChild($xpath, $function, false, false); + } + + /** + * @param XPathExpr $xpath + * @param FunctionNode $function + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function translateNthLastOfType(XPathExpr $xpath, FunctionNode $function) + { + if ('*' === $xpath->getElement()) { + throw new ExpressionErrorException('"*:nth-of-type()" is not implemented.'); + } + + return $this->translateNthChild($xpath, $function, true, false); + } + + /** + * @param XPathExpr $xpath + * @param FunctionNode $function + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function translateContains(XPathExpr $xpath, FunctionNode $function) + { + $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()) + )); + } + + /** + * @param XPathExpr $xpath + * @param FunctionNode $function + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function translateLang(XPathExpr $xpath, FunctionNode $function) + { + $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()) + )); + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'function'; + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/HtmlExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/HtmlExtension.php new file mode 100644 index 000000000000..aef80523dd09 --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/HtmlExtension.php @@ -0,0 +1,238 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class HtmlExtension extends AbstractExtension +{ + /** + * Constructor. + * + * @param Translator $translator + */ + public function __construct(Translator $translator) + { + $translator + ->getExtension('node') + ->setFlag(NodeExtension::ELEMENT_NAME_IN_LOWER_CASE, true) + ->setFlag(NodeExtension::ATTRIBUTE_NAME_IN_LOWER_CASE, true); + } + + /** + * {@inheritdoc} + */ + public function getPseudoClassTranslators() + { + return array( + 'checked' => array($this, 'translateChecked'), + 'link' => array($this, 'translateLink'), + 'disabled' => array($this, 'translateDisabled'), + 'enabled' => array($this, 'translateEnabled'), + 'selected' => array($this, 'translateSelected'), + 'invalid' => array($this, 'translateInvalid'), + 'hover' => array($this, 'translateHover'), + 'visited' => array($this, 'translateVisited'), + ); + } + + /** + * {@inheritdoc} + */ + public function getFunctionTranslators() + { + return array( + 'lang' => array($this, 'translateLang'), + ); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateChecked(XPathExpr $xpath) + { + return $xpath->addCondition( + '(@checked ' + ."and (name(.) = 'input' or name(.) = 'command')" + ."and (@type = 'checkbox' or @type = 'radio'))" + ); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateLink(XPathExpr $xpath) + { + return $xpath->addCondition("@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')"); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateDisabled(XPathExpr $xpath) + { + 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." + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateEnabled(XPathExpr $xpath) + { + 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]" + .')' + .')' + ); + } + + /** + * @param XPathExpr $xpath + * @param FunctionNode $function + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function translateLang(XPathExpr $xpath, FunctionNode $function) + { + $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()).'-') + )); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateSelected(XPathExpr $xpath) + { + return $xpath->addCondition("(@selected and name(.) = 'option')"); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateInvalid(XPathExpr $xpath) + { + return $xpath->addCondition('0'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateHover(XPathExpr $xpath) + { + return $xpath->addCondition('0'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateVisited(XPathExpr $xpath) + { + return $xpath->addCondition('0'); + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'html'; + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php new file mode 100644 index 000000000000..7d22d98de247 --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php @@ -0,0 +1,270 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class NodeExtension extends AbstractExtension +{ + const ELEMENT_NAME_IN_LOWER_CASE = 1; + const ATTRIBUTE_NAME_IN_LOWER_CASE = 2; + const ATTRIBUTE_VALUE_IN_LOWER_CASE = 4; + + /** + * @var Translator + */ + private $translator; + + /** + * @var int + */ + private $flags; + + /** + * Constructor. + * + * @param Translator $translator + * @param int $flags + */ + public function __construct(Translator $translator, $flags = 0) + { + $this->translator = $translator; + $this->flags = $flags; + } + + /** + * @param int $flag + * @param boolean $on + * + * @return NodeExtension + */ + public function setFlag($flag, $on) + { + if ($on && !$this->hasFlag($flag)) { + $this->flags += $flag; + } + + if (!$on && $this->hasFlag($flag)) { + $this->flags -= $flag; + } + + return $this; + } + + /** + * @param int $flag + * + * @return boolean + */ + public function hasFlag($flag) + { + return $this->flags & $flag; + } + + /** + * {@inheritdoc} + */ + public function getNodeTranslators() + { + return array( + 'Selector' => array($this, 'translateSelector'), + 'CombinedSelector' => array($this, 'translateCombinedSelector'), + 'Negation' => array($this, 'translateNegation'), + 'Function' => array($this, 'translateFunction'), + 'Pseudo' => array($this, 'translatePseudo'), + 'Attribute' => array($this, 'translateAttribute'), + 'Class' => array($this, 'translateClass'), + 'Hash' => array($this, 'translateHash'), + 'Element' => array($this, 'translateElement'), + ); + } + + /** + * @param Node\SelectorNode $node + * + * @return XPathExpr + */ + public function translateSelector(Node\SelectorNode $node) + { + return $this->translator->nodeToXPath($node->getTree()); + } + + /** + * @param Node\CombinedSelectorNode $node + * + * @return XPathExpr + */ + public function translateCombinedSelector(Node\CombinedSelectorNode $node) + { + return $this->translator->addCombination($node->getCombinator(), $node->getSelector(), $node->getSubSelector()); + } + + /** + * @param Node\NegationNode $node + * + * @return XPathExpr + */ + public function translateNegation(Node\NegationNode $node) + { + $xpath = $this->translator->nodeToXPath($node->getSelector()); + $subXpath = $this->translator->nodeToXPath($node->getSubSelector()); + $subXpath->addNameTest(); + + if ($subXpath->getCondition()) { + return $xpath->addCondition(sprintf('not(%s)', $subXpath->getCondition())); + } + + return $xpath->addCondition('0'); + } + + /** + * @param Node\FunctionNode $node + * + * @return XPathExpr + */ + public function translateFunction(Node\FunctionNode $node) + { + $xpath = $this->translator->nodeToXPath($node->getSelector()); + + return $this->translator->addFunction($xpath, $node); + } + + /** + * @param Node\PseudoNode $node + * + * @return XPathExpr + */ + public function translatePseudo(Node\PseudoNode $node) + { + $xpath = $this->translator->nodeToXPath($node->getSelector()); + + return $this->translator->addPseudoClass($xpath, $node->getIdentifier()); + } + + /** + * @param Node\AttributeNode $node + * + * @return XPathExpr + */ + public function translateAttribute(Node\AttributeNode $node) + { + $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 = $this->translator->nodeToXPath($node->getSelector()); + + if ($this->hasFlag(self::ATTRIBUTE_VALUE_IN_LOWER_CASE)) { + $value = strtolower($value); + } + + return $this->translator->addAttributeMatching($xpath, $node->getOperator(), $attribute, $value); + } + + /** + * @param Node\ClassNode $node + * + * @return XPathExpr + */ + public function translateClass(Node\ClassNode $node) + { + $xpath = $this->translator->nodeToXPath($node->getSelector()); + + return $this->translator->addAttributeMatching($xpath, '~=', '@class', $node->getName()); + } + + /** + * @param Node\HashNode $node + * + * @return XPathExpr + */ + public function translateHash(Node\HashNode $node) + { + $xpath = $this->translator->nodeToXPath($node->getSelector()); + + return $this->translator->addAttributeMatching($xpath, '=', '@id', $node->getId()); + } + + /** + * @param Node\ElementNode $node + * + * @return XPathExpr + */ + public function translateElement(Node\ElementNode $node) + { + $element = $node->getElement(); + + if ($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; + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'node'; + } + + /** + * Tests if given name is safe. + * + * @param string $name + * + * @return boolean + */ + private function isSafeName($name) + { + return 0 < preg_match('~^[a-zA-Z_][a-zA-Z0-9_.-]*$~', $name); + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/PseudoClassExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/PseudoClassExtension.php new file mode 100644 index 000000000000..d230dd7c483f --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/PseudoClassExtension.php @@ -0,0 +1,162 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class PseudoClassExtension extends AbstractExtension +{ + /** + * {@inheritdoc} + */ + public function getPseudoClassTranslators() + { + return array( + 'root' => array($this, 'translateRoot'), + 'first-child' => array($this, 'translateFirstChild'), + 'last-child' => array($this, 'translateLastChild'), + 'first-of-type' => array($this, 'translateFirstOfType'), + 'last-of-type' => array($this, 'translateLastOfType'), + 'only-child' => array($this, 'translateOnlyChild'), + 'only-of-type' => array($this, 'translateOnlyOfType'), + 'empty' => array($this, 'translateEmpty'), + ); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateRoot(XPathExpr $xpath) + { + return $xpath->addCondition('not(parent::*)'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateFirstChild(XPathExpr $xpath) + { + return $xpath + ->addStarPrefix() + ->addNameTest() + ->addCondition('position() = 1'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateLastChild(XPathExpr $xpath) + { + return $xpath + ->addStarPrefix() + ->addNameTest() + ->addCondition('position() = last()'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function translateFirstOfType(XPathExpr $xpath) + { + if ('*' === $xpath->getElement()) { + throw new ExpressionErrorException('"*:first-of-type" is not implemented.'); + } + + return $xpath + ->addStarPrefix() + ->addCondition('position() = 1'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function translateLastOfType(XPathExpr $xpath) + { + if ('*' === $xpath->getElement()) { + throw new ExpressionErrorException('"*:last-of-type" is not implemented.'); + } + + return $xpath + ->addStarPrefix() + ->addCondition('position() = last()'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateOnlyChild(XPathExpr $xpath) + { + return $xpath + ->addStarPrefix() + ->addNameTest() + ->addCondition('last() = 1'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function translateOnlyOfType(XPathExpr $xpath) + { + if ('*' === $xpath->getElement()) { + throw new ExpressionErrorException('"*:only-of-type" is not implemented.'); + } + + return $xpath->addCondition('last() = 1'); + } + + /** + * @param XPathExpr $xpath + * + * @return XPathExpr + */ + public function translateEmpty(XPathExpr $xpath) + { + return $xpath->addCondition('not(*) and not(string-length())'); + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'pseudo-class'; + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Translator.php b/src/Symfony/Component/CssSelector/XPath/Translator.php new file mode 100644 index 000000000000..3f566d94e8dc --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Translator.php @@ -0,0 +1,302 @@ + + * + * 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; +use Symfony\Component\CssSelector\XPath\Extension; + +/** + * XPath expression translator interface. + * + * This component is a port of the Python cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class Translator implements TranslatorInterface +{ + /** + * @var ParserInterface + */ + private $mainParser; + + /** + * @var ParserInterface[] + */ + private $shortcutParsers = array(); + + /** + * @var Extension\ExtensionInterface + */ + private $extensions = array(); + + /** + * @var array + */ + private $nodeTranslators = array(); + + /** + * @var array + */ + private $combinationTranslators = array(); + + /** + * @var array + */ + private $functionTranslators = array(); + + /** + * @var array + */ + private $pseudoClassTranslators = array(); + + /** + * @var array + */ + private $attributeMatchingTranslators = array(); + + /** + * Constructor. + */ + public function __construct(ParserInterface $parser = null) + { + $this->mainParser = $parser ?: new Parser(); + + $this + ->registerExtension(new Extension\NodeExtension($this)) + ->registerExtension(new Extension\CombinationExtension()) + ->registerExtension(new Extension\FunctionExtension()) + ->registerExtension(new Extension\PseudoClassExtension()) + ->registerExtension(new Extension\AttributeMatchingExtension()) + ; + } + + /** + * @param string $element + * + * @return string + */ + public static function getXpathLiteral($element) + { + if (false === strpos($element, "'")) { + return "'".$element."'"; + } + + if (false === strpos($element, '"')) { + return '"'.$element.'"'; + } + + $string = $element; + $parts = array(); + 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, ', ')); + } + + /** + * {@inheritdoc} + */ + public function cssToXPath($cssExpr, $prefix = 'descendant-or-self::') + { + $selectors = $this->parseSelectors($cssExpr); + + /** @var SelectorNode $selector */ + foreach ($selectors as $selector) { + if (null !== $selector->getPseudoElement()) { + throw new ExpressionErrorException('Pseudo-elements are not supported.'); + } + } + + $translator = $this; + + return implode(' | ', array_map(function (SelectorNode $selector) use ($translator, $prefix) { + return $translator->selectorToXPath($selector, $prefix); + }, $selectors)); + } + + /** + * {@inheritdoc} + */ + public function selectorToXPath(SelectorNode $selector, $prefix = 'descendant-or-self::') + { + return ($prefix ?: '').$this->nodeToXPath($selector); + } + + /** + * Registers an extension. + * + * @param Extension\ExtensionInterface $extension + * + * @return Translator + */ + public function registerExtension(Extension\ExtensionInterface $extension) + { + $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()); + + return $this; + } + + /** + * @param string $name + * + * @return Extension\ExtensionInterface + * + * @throws ExpressionErrorException + */ + public function getExtension($name) + { + if (!isset($this->extensions[$name])) { + throw new ExpressionErrorException('Extension "'.$name.'" not registered.'); + } + + return $this->extensions[$name]; + } + + /** + * Registers a shortcut parser. + * + * @param ParserInterface $shortcut + * + * @return Translator + */ + public function registerParserShortcut(ParserInterface $shortcut) + { + $this->shortcutParsers[] = $shortcut; + + return $this; + } + + /** + * @param NodeInterface $node + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function nodeToXPath(NodeInterface $node) + { + if (!isset($this->nodeTranslators[$node->getNodeName()])) { + throw new ExpressionErrorException('Node "'.$node->getNodeName().'" not supported.'); + } + + return call_user_func($this->nodeTranslators[$node->getNodeName()], $node); + } + + /** + * @param string $combiner + * @param NodeInterface $xpath + * @param NodeInterface $combinedXpath + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function addCombination($combiner, NodeInterface $xpath, NodeInterface $combinedXpath) + { + if (!isset($this->combinationTranslators[$combiner])) { + throw new ExpressionErrorException('Combiner "'.$combiner.'" not supported.'); + } + + return call_user_func($this->combinationTranslators[$combiner], $this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath)); + } + + /** + * @param XPathExpr $xpath + * @param FunctionNode $function + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function addFunction(XPathExpr $xpath, FunctionNode $function) + { + if (!isset($this->functionTranslators[$function->getName()])) { + throw new ExpressionErrorException('Function "'.$function->getName().'" not supported.'); + } + + return call_user_func($this->functionTranslators[$function->getName()], $xpath, $function); + } + + /** + * @param XPathExpr $xpath + * @param string $pseudoClass + * + * @return XPathExpr + * + * @throws ExpressionErrorException + */ + public function addPseudoClass(XPathExpr $xpath, $pseudoClass) + { + if (!isset($this->pseudoClassTranslators[$pseudoClass])) { + throw new ExpressionErrorException('Pseudo-class "'.$pseudoClass.'" not supported.'); + } + + return call_user_func($this->pseudoClassTranslators[$pseudoClass], $xpath); + } + + /** + * @param XPathExpr $xpath + * @param string $operator + * @param string $attribute + * @param string $value + * + * @throws ExpressionErrorException + * + * @return XPathExpr + */ + public function addAttributeMatching(XPathExpr $xpath, $operator, $attribute, $value) + { + if (!isset($this->attributeMatchingTranslators[$operator])) { + throw new ExpressionErrorException('Attribute matcher operator "'.$operator.'" not supported.'); + } + + return call_user_func($this->attributeMatchingTranslators[$operator], $xpath, $attribute, $value); + } + + /** + * @param string $css + * + * @return SelectorNode[] + */ + private function parseSelectors($css) + { + foreach ($this->shortcutParsers as $shortcut) { + $tokens = $shortcut->parse($css); + + if (!empty($tokens)) { + return $tokens; + } + } + + return $this->mainParser->parse($css); + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/TranslatorInterface.php b/src/Symfony/Component/CssSelector/XPath/TranslatorInterface.php new file mode 100644 index 000000000000..b26cf5b0c399 --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/TranslatorInterface.php @@ -0,0 +1,45 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +interface TranslatorInterface +{ + /** + * Translates a CSS selector to an XPath expression. + * + * @param string $cssExpr + * @param string $prefix + * + * @return XPathExpr + */ + public function cssToXPath($cssExpr, $prefix = 'descendant-or-self::'); + + /** + * Translates a parsed selector node to an XPath expression + * + * @param SelectorNode $selector + * @param string $prefix + * + * @return XPathExpr + */ + public function selectorToXPath(SelectorNode $selector, $prefix = 'descendant-or-self::'); +} diff --git a/src/Symfony/Component/CssSelector/XPath/XPathExpr.php b/src/Symfony/Component/CssSelector/XPath/XPathExpr.php new file mode 100644 index 000000000000..6f5162e0ce6a --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/XPathExpr.php @@ -0,0 +1,140 @@ + + * + * 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 cssselector library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Jean-François Simon + */ +class XPathExpr +{ + /** + * @var string + */ + private $path; + + /** + * @var string + */ + private $element; + + /** + * @var string + */ + private $condition; + + /** + * @param string $path + * @param string $element + * @param string $condition + * @param boolean $starPrefix + */ + public function __construct($path = '', $element = '*', $condition = '', $starPrefix = false) + { + $this->path = $path; + $this->element = $element; + $this->condition = $condition; + + if ($starPrefix) { + $this->addStarPrefix(); + } + } + + /** + * @return string + */ + public function getElement() + { + return $this->element; + } + + /** + * @param $condition + * + * @return XPathExpr + */ + public function addCondition($condition) + { + $this->condition = $this->condition ? sprintf('%s and (%s)', $this->condition, $condition) : $condition; + + return $this; + } + + /** + * @return string + */ + public function getCondition() + { + return $this->condition; + } + + /** + * @return XPathExpr + */ + public function addNameTest() + { + if ('*' !== $this->element) { + $this->addCondition('name() = '.Translator::getXpathLiteral($this->element)); + $this->element = '*'; + } + + return $this; + } + + /** + * @return XPathExpr + */ + public function addStarPrefix() + { + $this->path .= '*/'; + + return $this; + } + + /** + * Joins another XPathExpr with a combiner. + * + * @param string $combiner + * @param XPathExpr $expr + * + * @return XPathExpr + */ + public function join($combiner, XPathExpr $expr) + { + $path = $this->__toString().$combiner; + + if ('*/' !== $expr->path) { + $path .= $expr->path; + } + + $this->path = $path; + $this->element = $expr->element; + $this->condition = $expr->condition; + + return $this; + } + + /** + * @return string + */ + public function __toString() + { + $path = $this->path.$this->element; + $condition = null === $this->condition || '' === $this->condition ? '' : '['.$this->condition.']'; + + return $path.$condition; + } +} diff --git a/src/Symfony/Component/CssSelector/XPathExpr.php b/src/Symfony/Component/CssSelector/XPathExpr.php deleted file mode 100644 index 507b8ac16b57..000000000000 --- a/src/Symfony/Component/CssSelector/XPathExpr.php +++ /dev/null @@ -1,254 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\CssSelector; - -/** - * XPathExpr represents an XPath expression. - * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. - * - * @author Fabien Potencier - */ -class XPathExpr -{ - private $prefix; - private $path; - private $element; - private $condition; - private $starPrefix; - - /** - * Constructor. - * - * @param string $prefix Prefix for the XPath expression. - * @param string $path Actual path of the expression. - * @param string $element The element in the expression. - * @param string $condition A condition for the expression. - * @param Boolean $starPrefix Indicates whether to use a star prefix. - */ - public function __construct($prefix = null, $path = null, $element = '*', $condition = null, $starPrefix = false) - { - $this->prefix = $prefix; - $this->path = $path; - $this->element = $element; - $this->condition = $condition; - $this->starPrefix = $starPrefix; - } - - /** - * Gets the prefix of this XPath expression. - * - * @return string - */ - public function getPrefix() - { - return $this->prefix; - } - - /** - * Gets the path of this XPath expression. - * - * @return string - */ - public function getPath() - { - return $this->path; - } - - /** - * Answers whether this XPath expression has a star prefix. - * - * @return Boolean - */ - public function hasStarPrefix() - { - return $this->starPrefix; - } - - /** - * Gets the element of this XPath expression. - * - * @return string - */ - public function getElement() - { - return $this->element; - } - - /** - * Gets the condition of this XPath expression. - * - * @return string - */ - public function getCondition() - { - return $this->condition; - } - - /** - * Gets a string representation for this XPath expression. - * - * @return string - */ - public function __toString() - { - $path = ''; - if (null !== $this->prefix) { - $path .= $this->prefix; - } - - if (null !== $this->path) { - $path .= $this->path; - } - - $path .= $this->element; - - if ($this->condition) { - $path .= sprintf('[%s]', $this->condition); - } - - return $path; - } - - /** - * Adds a condition to this XPath expression. - * Any pre-existent condition will be ANDed to it. - * - * @param string $condition The condition to add. - */ - public function addCondition($condition) - { - if ($this->condition) { - $this->condition = sprintf('%s and (%s)', $this->condition, $condition); - } else { - $this->condition = $condition; - } - } - - /** - * Adds a prefix to this XPath expression. - * It will be prepended to any pre-existent prefixes. - * - * @param string $prefix The prefix to add. - */ - public function addPrefix($prefix) - { - if ($this->prefix) { - $this->prefix = $prefix.$this->prefix; - } else { - $this->prefix = $prefix; - } - } - - /** - * Adds a condition to this XPath expression using the name of the element - * as the desired value. - * This method resets the element to '*'. - */ - public function addNameTest() - { - if ($this->element == '*') { - // We weren't doing a test anyway - return; - } - - $this->addCondition(sprintf('name() = %s', XPathExpr::xpathLiteral($this->element))); - $this->element = '*'; - } - - /** - * Adds a star prefix to this XPath expression. - * This method will prepend a '*' to the path and set the star prefix flag - * to true. - */ - public function addStarPrefix() - { - /* - Adds a /* prefix if there is no prefix. This is when you need - to keep context's constrained to a single parent. - */ - if ($this->path) { - $this->path .= '*/'; - } else { - $this->path = '*/'; - } - - $this->starPrefix = true; - } - - /** - * Joins this XPath expression with $other (another XPath expression) using - * $combiner to join them. - * - * @param string $combiner The combiner string. - * @param XPathExpr $other The other XPath expression to combine with - * this one. - */ - public function join($combiner, $other) - { - $prefix = (string) $this; - - $prefix .= $combiner; - $path = $other->getPrefix().$other->getPath(); - - /* We don't need a star prefix if we are joining to this other - prefix; so we'll get rid of it */ - if ($other->hasStarPrefix() && '*/' == $path) { - $path = ''; - } - $this->prefix = $prefix; - $this->path = $path; - $this->element = $other->getElement(); - $this->condition = $other->GetCondition(); - } - - /** - * Gets an XPath literal for $s. - * - * @param mixed $s Can either be a Node\ElementNode or a string. - * - * @return string - */ - public static function xpathLiteral($s) - { - if ($s instanceof Node\ElementNode) { - // This is probably a symbol that looks like an expression... - $s = $s->formatElement(); - } else { - $s = (string) $s; - } - - if (false === strpos($s, "'")) { - return sprintf("'%s'", $s); - } - - if (false === strpos($s, '"')) { - return sprintf('"%s"', $s); - } - - $string = $s; - $parts = array(); - 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, ', ')); - } -} diff --git a/src/Symfony/Component/CssSelector/XPathExprOr.php b/src/Symfony/Component/CssSelector/XPathExprOr.php deleted file mode 100644 index f516367fe0b7..000000000000 --- a/src/Symfony/Component/CssSelector/XPathExprOr.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\CssSelector; - -/** - * XPathExprOr represents XPath |'d expressions. - * - * Note that unfortunately it isn't the union, it's the sum, so duplicate elements will appear. - * - * This component is a port of the Python lxml library, - * which is copyright Infrae and distributed under the BSD license. - * - * @author Fabien Potencier - */ -class XPathExprOr extends XPathExpr -{ - /** - * Constructor. - * - * @param array $items The items in the expression. - * @param string $prefix Optional prefix for the expression. - */ - public function __construct($items, $prefix = null) - { - $this->items = $items; - $this->prefix = $prefix; - } - - /** - * Gets a string representation of this |'d expression. - * - * @return string - */ - public function __toString() - { - $prefix = $this->getPrefix(); - - $tmp = array(); - foreach ($this->items as $i) { - $tmp[] = sprintf('%s%s', $prefix, $i); - } - - return implode($tmp, ' | '); - } -} diff --git a/src/Symfony/Component/CssSelector/composer.json b/src/Symfony/Component/CssSelector/composer.json index 4a2f46192c2c..12d8c8ee5fac 100644 --- a/src/Symfony/Component/CssSelector/composer.json +++ b/src/Symfony/Component/CssSelector/composer.json @@ -10,6 +10,10 @@ "name": "Fabien Potencier", "email": "fabien@symfony.com" }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, { "name": "Symfony Community", "homepage": "http://symfony.com/contributors"