From 328481e8a1a540ebaea0fe5cf70e2f321f1fbbdb Mon Sep 17 00:00:00 2001 From: Jake Hotson Date: Sun, 1 Feb 2026 01:09:56 +0000 Subject: [PATCH 1/3] [TASK] Add `CompoundSelector` class Part of #1325. --- src/Property/Selector/CompoundSelector.php | 255 +++++++++ .../Selector/CompoundSelectorTest.php | 494 ++++++++++++++++++ 2 files changed, 749 insertions(+) create mode 100644 src/Property/Selector/CompoundSelector.php create mode 100644 tests/Unit/Property/Selector/CompoundSelectorTest.php diff --git a/src/Property/Selector/CompoundSelector.php b/src/Property/Selector/CompoundSelector.php new file mode 100644 index 00000000..77cf67b3 --- /dev/null +++ b/src/Property/Selector/CompoundSelector.php @@ -0,0 +1,255 @@ +`, `+`, `~`) before being passed to this class. + */ +class CompoundSelector implements Renderable, Component +{ + use ShortClassNameProvider; + + 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; + static $stopCharacters = [ + '{', + '}', + '\'', + '"', + '(', + ')', + ',', + ' ', + "\t", + "\n", + "\r", + '>', + '+', + '~', + ParserState::EOF, + '', + ]; + + while (true) { + $selectorParts[] = $parserState->consumeUntil($stopCharacters, 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..0912f728 --- /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']); + } +} From 32381fc1a4123286100d9c91e5a0d10602e55dc9 Mon Sep 17 00:00:00 2001 From: Jake Hotson Date: Wed, 4 Feb 2026 22:55:04 +0000 Subject: [PATCH 2/3] Enclose test strings in double quotes if they contain single quotes (only) --- tests/Unit/Property/Selector/CompoundSelectorTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Property/Selector/CompoundSelectorTest.php b/tests/Unit/Property/Selector/CompoundSelectorTest.php index 0912f728..1e3f6bd6 100644 --- a/tests/Unit/Property/Selector/CompoundSelectorTest.php +++ b/tests/Unit/Property/Selector/CompoundSelectorTest.php @@ -67,16 +67,16 @@ 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\']'], + '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\']'], + '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'], + => [".before\\:content-\\[\\'\\'\\]:before"], 'escaped double quotes in selector itself, with other escaped characters' => ['.before\\:content-\\[\\"\\"\\]:before'], ]; @@ -169,7 +169,7 @@ 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 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\']'], From 8247d8ba1292940764cc4a7dcd7daf3d265251b6 Mon Sep 17 00:00:00 2001 From: Jake Hotson Date: Wed, 4 Feb 2026 22:56:40 +0000 Subject: [PATCH 3/3] Move stop characters to a constant --- src/Property/Selector/CompoundSelector.php | 39 +++++++++++----------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Property/Selector/CompoundSelector.php b/src/Property/Selector/CompoundSelector.php index 77cf67b3..1073a6cb 100644 --- a/src/Property/Selector/CompoundSelector.php +++ b/src/Property/Selector/CompoundSelector.php @@ -21,6 +21,25 @@ 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 @@ -82,27 +101,9 @@ public static function parse(ParserState $parserState, array &$comments = []): s $selectorParts = []; $stringWrapperCharacter = null; $functionNestingLevel = 0; - static $stopCharacters = [ - '{', - '}', - '\'', - '"', - '(', - ')', - ',', - ' ', - "\t", - "\n", - "\r", - '>', - '+', - '~', - ParserState::EOF, - '', - ]; while (true) { - $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments); + $selectorParts[] = $parserState->consumeUntil(self::PARSER_STOP_CHARACTERS, false, false, $comments); $nextCharacter = $parserState->peek(); switch ($nextCharacter) { case '':