diff --git a/src/Property/Selector/CompoundSelector.php b/src/Property/Selector/CompoundSelector.php new file mode 100644 index 00000000..1073a6cb --- /dev/null +++ b/src/Property/Selector/CompoundSelector.php @@ -0,0 +1,256 @@ +`, `+`, `~`) before being passed to this class. + */ +class CompoundSelector implements Renderable, Component +{ + use ShortClassNameProvider; + + private const PARSER_STOP_CHARACTERS = [ + '{', + '}', + '\'', + '"', + '(', + ')', + ',', + ' ', + "\t", + "\n", + "\r", + '>', + '+', + '~', + ParserState::EOF, + '', // `ParserState::peek()` returns empty string rather than `ParserState::EOF` when end of string is reached + ]; + + private const SELECTOR_VALIDATION_RX = '/ + ^ + # not starting with whitespace + (?!\\s) + (?: + (?: + # any sequence of valid unescaped characters, except quotes + [a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=\\[\\]()\\-\\.:#,\\s]++ + | + # one or more escaped characters + (?:\\\\.)++ + | + # quoted text, like in `[id="example"]` + (?: + # opening quote + ([\'"]) + (?: + # sequence of characters except closing quote or backslash + (?:(?!\\g{-1}|\\\\).)++ + | + # one or more escaped characters + (?:\\\\.)++ + )*+ # zero or more times + # closing quote or end (unmatched quote is currently allowed) + (?:\\g{-1}|$) + ) + )++ # one or more times + | + # keyframe animation progress percentage (e.g. 50%) + (?:\\d++%) + ) + # not ending with whitespace + (?setValue($value); + } + + /** + * @param list $comments + * + * @throws UnexpectedTokenException + * + * @internal + */ + public static function parse(ParserState $parserState, array &$comments = []): self + { + $selectorParts = []; + $stringWrapperCharacter = null; + $functionNestingLevel = 0; + + while (true) { + $selectorParts[] = $parserState->consumeUntil(self::PARSER_STOP_CHARACTERS, false, false, $comments); + $nextCharacter = $parserState->peek(); + switch ($nextCharacter) { + case '': + // EOF + break 2; + case '\'': + // The fallthrough is intentional. + case '"': + $lastPart = \end($selectorParts); + $backslashCount = \strspn(\strrev($lastPart), '\\'); + $quoteIsEscaped = ($backslashCount % 2 === 1); + if (!$quoteIsEscaped) { + if (!\is_string($stringWrapperCharacter)) { + $stringWrapperCharacter = $nextCharacter; + } elseif ($stringWrapperCharacter === $nextCharacter) { + $stringWrapperCharacter = null; + } + } + break; + case '(': + if (!\is_string($stringWrapperCharacter)) { + ++$functionNestingLevel; + } + break; + case ')': + if (!\is_string($stringWrapperCharacter)) { + if ($functionNestingLevel <= 0) { + throw new UnexpectedTokenException( + 'anything but', + ')', + 'literal', + $parserState->currentLine() + ); + } + --$functionNestingLevel; + } + break; + case '{': + // The fallthrough is intentional. + case '}': + if (!\is_string($stringWrapperCharacter)) { + break 2; + } + break; + case ',': + // The fallthrough is intentional. + case ' ': + // The fallthrough is intentional. + case "\t": + // The fallthrough is intentional. + case "\n": + // The fallthrough is intentional. + case "\r": + // The fallthrough is intentional. + case '>': + // The fallthrough is intentional. + case '+': + // The fallthrough is intentional. + case '~': + if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) { + break 2; + } + break; + } + $selectorParts[] = $parserState->consume(1); + } + + if ($functionNestingLevel !== 0) { + throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine()); + } + if (\is_string($stringWrapperCharacter)) { + throw new UnexpectedTokenException( + $stringWrapperCharacter, + $nextCharacter, + 'literal', + $parserState->currentLine() + ); + } + + $value = \implode('', $selectorParts); + if ($value === '') { + throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine()); + } + if (!self::isValid($value)) { + throw new UnexpectedTokenException( + 'Selector component is not valid:', + '`' . $value . '`', + 'custom', + $parserState->currentLine() + ); + } + + return new self($value); + } + + /** + * @return non-empty-string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @param non-empty-string $value + * + * @throws \UnexpectedValueException if `$value` contains invalid characters or has surrounding whitespce + */ + public function setValue(string $value): void + { + if (!self::isValid($value)) { + throw new \UnexpectedValueException('`' . $value . '` is not a valid compound selector.'); + } + + $this->value = $value; + } + + /** + * @return int<0, max> + */ + public function getSpecificity(): int + { + return SpecificityCalculator::calculate($this->value); + } + + public function render(OutputFormat $outputFormat): string + { + return $this->getValue(); + } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + 'value' => $this->value, + ]; + } + + private static function isValid(string $value): bool + { + $numberOfMatches = preg_match(self::SELECTOR_VALIDATION_RX, $value); + + return $numberOfMatches === 1; + } +} diff --git a/tests/Unit/Property/Selector/CompoundSelectorTest.php b/tests/Unit/Property/Selector/CompoundSelectorTest.php new file mode 100644 index 00000000..1e3f6bd6 --- /dev/null +++ b/tests/Unit/Property/Selector/CompoundSelectorTest.php @@ -0,0 +1,494 @@ +}> + */ + public static function provideCompoundSelectorAndSpecificity(): array + { + return [ + 'type' => ['a', 1], + 'class' => ['.highlighted', 10], + 'type with class' => ['li.green', 11], + 'pseudo-class' => [':hover', 10], + 'type with pseudo-class' => ['a:hover', 11], + 'class with pseudo-class' => ['.help:hover', 20], + 'ID' => ['#file', 100], + 'type with ID' => ['h2#my-mug', 101], + 'pseudo-element' => ['::before', 1], + 'type with pseudo-element' => ['li::before', 2], + '`not`' => [':not(#your-mug)', 100], + // TODO, broken: The specificity should be the highest of the `:not` arguments, not the sum. + '`not` with multiple arguments' => [':not(#your-mug, .their-mug)', 110], + 'attribute with `"`' => ['[alt="{}()[]\\"\',"]', 10], + 'attribute with `\'`' => ['[alt=\'{}()[]"\\\',\']', 10], + ]; + } + + /** + * @return array + */ + public static function provideCompoundSelectorWithEscapedQuotes(): array + { + return [ + 'escaped double quote in double-quoted attribute' => ['a[href="test\\"value"]'], + 'escaped single quote in single-quoted attribute' => ["a[href='test\\'value']"], + 'multiple escaped double quotes in double-quoted attribute' => ['a[title="say \\"hello\\" world"]'], + 'multiple escaped single quotes in single-quoted attribute' => ["a[title='say \\'hello\\' world']"], + 'escaped quote at start of attribute value' => ['a[data-test="\\"start"]'], + 'escaped quote at end of attribute value' => ['a[data-test="end\\""]'], + 'escaped backslash followed by quote' => ['a[data-test="test\\\\"]'], + 'escaped backslash before escaped quote' => ['a[data-test="test\\\\\\"value"]'], + 'triple backslash before quote' => ['a[data-test="test\\\\\\""]'], + 'escaped single quotes in selector itself, with other escaped characters' + => [".before\\:content-\\[\\'\\'\\]:before"], + 'escaped double quotes in selector itself, with other escaped characters' + => ['.before\\:content-\\[\\"\\"\\]:before'], + ]; + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * + * @dataProvider provideCompoundSelectorAndSpecificity + * @dataProvider provideCompoundSelectorWithEscapedQuotes + */ + public function parsesValidCompoundSelector(string $compoundSelector): void + { + $result = CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + + self::assertInstanceOf(CompoundSelector::class, $result); + self::assertSame($compoundSelector, $result->getValue()); + } + + /** + * @test + */ + public function parsingAttributeWithEscapedQuoteDoesNotPrematurelyCloseString(): void + { + $compoundSelector = 'input[placeholder="Enter \\"quoted\\" text here"]'; + + $result = CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + + self::assertInstanceOf(CompoundSelector::class, $result); + self::assertSame($compoundSelector, $result->getValue()); + } + + /** + * @test + */ + public function parseDistinguishesEscapedFromUnescapedQuotes(): void + { + // One backslash = escaped quote (should not close string) + $compoundSelector = 'a[data-value="test\\"more"]'; + + $result = CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + + self::assertSame($compoundSelector, $result->getValue()); + } + + /** + * @test + */ + public function parseHandlesEvenNumberOfBackslashesBeforeQuote(): void + { + // Two backslashes = escaped backslash + unescaped quote (should close string) + $compoundSelector = 'a[data-value="test\\\\"]'; + + $result = CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + + self::assertSame($compoundSelector, $result->getValue()); + } + + /** + * @return array + */ + public static function provideInvalidCompoundSelector(): array + { + return [ + 'empty string' => [''], + 'space' => [' '], + 'tab' => ["\t"], + 'line feed' => ["\n"], + 'carriage return' => ["\r"], + 'percent sign' => ['%'], + // This is currently broken. + // 'hash only' => ['#'], + // This is currently broken. + // 'dot only' => ['.'], + 'slash' => ['/'], + 'less-than sign' => ['<'], + 'with space before' => [' a:hover'], + ]; + } + + /** + * The validation checks on `setValue()` are not able to pick up these, because it does not perform any parsing. + * + * @return array + */ + public static function provideInvalidCompoundSelectorForParse(): array + { + return [ + 'a `:not` missing the closing brace' => [':not(a'], + 'a `:not` missing the opening brace' => [':nota)'], + 'attribute value missing closing single quote' => ["a[href='#top]"], + 'attribute value missing closing double quote' => ['a[href="#top]'], + 'attribute value with mismatched quotes, single quote opening' => ['a[href=\'#top"]'], + 'attribute value with mismatched quotes, double quote opening' => ['a[href="#top\']'], + ]; + } + + /** + * This is valid in a parsing context, but not when passed to `setValue()` or the constructor. + * + * @return array + */ + public static function provideInvalidCompoundSelectorForSetValue(): array + { + return [ + 'with space after' => ['a:hover '], + ]; + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * + * @dataProvider provideInvalidCompoundSelector + * @dataProvider provideInvalidCompoundSelectorForParse + */ + public function parseThrowsExceptionWithInvalidCompoundSelector(string $compoundSelector): void + { + $this->expectException(UnexpectedTokenException::class); + + CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + } + + /** + * @return array + */ + public static function provideStopCharacters(): array + { + return [ + ',' => [','], + '{' => ['{'], + '}' => ['}'], + 'space' => [' '], + 'tab' => ["\t"], + 'line feed' => ["\n"], + 'carriage return' => ["\r"], + 'child combinator' => ['>'], + 'next-sibling combinator' => ['+'], + 'subsequent-sibling combinator' => ['~'], + ]; + } + + /** + * @return DataProvider + */ + public function provideStopCharactersAndValidCompoundSelector(): DataProvider + { + return DataProvider::cross(self::provideStopCharacters(), self::provideCompoundSelectorAndSpecificity()); + } + + /** + * @test + * + * @param non-empty-string $stopCharacter + * @param non-empty-string $compoundSelector + * + * @dataProvider provideStopCharactersAndValidCompoundSelector + */ + public function parseDoesNotConsumeStopCharacter(string $stopCharacter, string $compoundSelector): void + { + $subject = new ParserState($compoundSelector . $stopCharacter, Settings::create()); + + CompoundSelector::parse($subject); + + self::assertSame($stopCharacter, $subject->peek()); + } + + /** + * @return array + */ + public static function provideCompoundSelectorWithAndWithoutComment(): array + { + return [ + 'comment before' => ['/*comment*/body.page', 'body.page'], + 'comment after' => ['body.page/*comment*/', 'body.page'], + 'comment within' => ['p./*comment*/teapot', 'p.teapot'], + 'comment within function' => ['p:not(#your-mug,/*comment*/.their-mug)', 'p:not(#your-mug,.their-mug)'], + ]; + } + + /** + * @test + * + * @param non-empty-string $compoundSelectorWith + * @param non-empty-string $compoundSelectorWithout + * + * @dataProvider provideCompoundSelectorWithAndWithoutComment + */ + public function parsesCompoundSelectorWithComment( + string $compoundSelectorWith, + string $compoundSelectorWithout + ): void { + $result = CompoundSelector::parse(new ParserState($compoundSelectorWith, Settings::create())); + + self::assertInstanceOf(CompoundSelector::class, $result); + self::assertSame($compoundSelectorWithout, $result->getValue()); + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * + * @dataProvider provideCompoundSelectorWithAndWithoutComment + */ + public function parseExtractsCommentFromCompoundSelector(string $compoundSelector): void + { + $result = []; + CompoundSelector::parse(new ParserState($compoundSelector, Settings::create()), $result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame('comment', $result[0]->getComment()); + } + + /** + * @test + */ + public function parsesCompoundSelectorWithTwoComments(): void + { + $result = CompoundSelector::parse(new ParserState('/*comment1*/a:hover/*comment2*/', Settings::create())); + + self::assertInstanceOf(CompoundSelector::class, $result); + self::assertSame('a:hover', $result->getValue()); + } + + /** + * @test + */ + public function parseExtractsTwoCommentsFromCompoundSelector(): void + { + $result = []; + CompoundSelector::parse(new ParserState('/*comment1*/a:hover/*comment2*/', Settings::create()), $result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame('comment1', $result[0]->getComment()); + self::assertInstanceOf(Comment::class, $result[1]); + self::assertSame('comment2', $result[1]->getComment()); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function constructsWithValueProvided(string $value): void + { + $subject = new CompoundSelector($value); + + self::assertSame($value, $subject->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideInvalidCompoundSelector + * @dataProvider provideInvalidCompoundSelectorForSetValue + */ + public function constructorThrowsExceptionWithInvalidValue(string $value): void + { + $this->expectException(\UnexpectedValueException::class); + + new CompoundSelector($value); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function setValueSetsValueProvided(string $value): void + { + $subject = new CompoundSelector('p.intro'); + + $subject->setValue($value); + + self::assertSame($value, $subject->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideInvalidCompoundSelector + * @dataProvider provideInvalidCompoundSelectorForSetValue + */ + public function setValueThrowsExceptionWithInvalidValue(string $value): void + { + $this->expectException(\UnexpectedValueException::class); + + $subject = new CompoundSelector('p.intro'); + + $subject->setValue($value); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getValueReturnsValueProvidedToConstructor(string $value): void + { + $subject = new CompoundSelector($value); + + $result = $subject->getValue(); + + self::assertSame($value, $result); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getValueReturnsValueProvidedToSetValue(string $value): void + { + $subject = new CompoundSelector('p.intro'); + $subject->setValue($value); + + $result = $subject->getValue(); + + self::assertSame($value, $result); + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * @param int<0, max> $expectedSpecificity + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getSpecificityByDefaultReturnsSpecificityOfCompoundSelectorProvidedToConstructor( + string $compoundSelector, + int $expectedSpecificity + ): void { + $subject = new CompoundSelector($compoundSelector); + + self::assertSame($expectedSpecificity, $subject->getSpecificity()); + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * @param int<0, max> $expectedSpecificity + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getSpecificityReturnsSpecificityOfCompoundSelectorLastProvidedViaSetValue( + string $compoundSelector, + int $expectedSpecificity + ): void { + $subject = new CompoundSelector('p.intro'); + + $subject->setValue($compoundSelector); + + self::assertSame($expectedSpecificity, $subject->getSpecificity()); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function renderReturnsValueProvided(string $value): void + { + $subject = new CompoundSelector($value); + + self::assertSame($value, $subject->render(OutputFormat::create())); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new CompoundSelector('a:hover'); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('CompoundSelector', $result['class']); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getArrayRepresentationIncludesValue(string $value): void + { + $subject = new CompoundSelector($value); + + $result = $subject->getArrayRepresentation(); + + self::assertSame($value, $result['value']); + } +}