diff --git a/CHANGELOG.md b/CHANGELOG.md index 179a6cfa..f14ec704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ Please also have a look at our ### Changed +- `Selector` is now represented as a sequence of `Selector\Component` objects + which can be accessed via `getComponents()`, manipulated individually, or set + via `setComponents()` (#1478, #1486, #1487, #1488, #1494, #1496) - `Selector::setSelector()` and `Selector` constructor will now throw exception upon provision of an invalid selectior (#1498, #1502) - Clean up extra whitespace in CSS selector (#1398) diff --git a/src/Property/Selector.php b/src/Property/Selector.php index 4474d722..66c061ad 100644 --- a/src/Property/Selector.php +++ b/src/Property/Selector.php @@ -11,12 +11,11 @@ use Sabberworm\CSS\Property\Selector\Combinator; use Sabberworm\CSS\Property\Selector\Component; use Sabberworm\CSS\Property\Selector\CompoundSelector; -use Sabberworm\CSS\Property\Selector\SpecificityCalculator; use Sabberworm\CSS\Renderable; +use Sabberworm\CSS\Settings; use Sabberworm\CSS\ShortClassNameProvider; use function Safe\preg_match; -use function Safe\preg_replace; /** * Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this @@ -59,9 +58,9 @@ class Selector implements Renderable /ux'; /** - * @var non-empty-string + * @var non-empty-list */ - private $selector; + private $components; /** * @internal since V8.8.0 @@ -75,13 +74,18 @@ public static function isValid(string $selector): bool } /** - * @param non-empty-string $selector + * @param non-empty-string|non-empty-list $selector + * Providing a string is deprecated in version 9.2 and will not work from v10.0 * - * @throws \UnexpectedValueException if the selector is not valid + * @throws UnexpectedTokenException if the selector is not valid */ - final public function __construct(string $selector) + final public function __construct($selector) { - $this->setSelector($selector); + if (\is_string($selector)) { + $this->setSelector($selector); + } else { + $this->setComponents($selector); + } } /** @@ -144,44 +148,62 @@ public static function parse(ParserState $parserState, array &$comments = []): s ); } - $selectorString = ''; - foreach ($selectorParts as $selectorPart) { - $selectorPartValue = $selectorPart->getValue(); - if (\in_array($selectorPartValue, ['>', '+', '~'], true)) { - $selectorString .= ' ' . $selectorPartValue . ' '; - } else { - $selectorString .= $selectorPartValue; - } - } + return new static($selectorParts); + } - return new static($selectorString); + /** + * @return non-empty-list + */ + public function getComponents(): array + { + return $this->components; + } + + /** + * @param non-empty-list $components + * This should be an alternating sequence of `CompoundSelector` and `Combinator`, starting and ending with a + * `CompoundSelector`, and may be a single `CompoundSelector`. + */ + public function setComponents(array $components): self + { + $this->components = $components; + + return $this; } /** * @return non-empty-string + * + * @deprecated in version 9.2, will be removed in v10.0. Use either `getComponents()` or `render()` instead. */ public function getSelector(): string { - return $this->selector; + return $this->render(new OutputFormat()); } /** * @param non-empty-string $selector * - * @throws \UnexpectedValueException if the selector is not valid + * @throws UnexpectedTokenException if the selector is not valid + * + * @deprecated in version 9.2, will be removed in v10.0. Use `setComponents()` instead. */ public function setSelector(string $selector): void { - if (!self::isValid($selector)) { - throw new \UnexpectedValueException("Selector `$selector` is not valid."); - } + $parserState = new ParserState($selector, Settings::create()); - $selector = \trim($selector); + $components = self::parseComponents($parserState); - $hasAttribute = \strpos($selector, '[') !== false; + // Check that the selector has been fully parsed: + if (!$parserState->isEnd()) { + throw new UnexpectedTokenException( + 'EOF', + $parserState->peek(5), + 'literal' + ); + } - // Whitespace can't be adjusted within an attribute selector, as it would change its meaning - $this->selector = !$hasAttribute ? preg_replace('/\\s++/', ' ', $selector) : $selector; + $this->components = $components; } /** @@ -189,12 +211,22 @@ public function setSelector(string $selector): void */ public function getSpecificity(): int { - return SpecificityCalculator::calculate($this->selector); + return \array_sum(\array_map( + static function (Component $component): int { + return $component->getSpecificity(); + }, + $this->components + )); } public function render(OutputFormat $outputFormat): string { - return $this->getSelector(); + return \implode('', \array_map( + static function (Component $component) use ($outputFormat): string { + return $component->render($outputFormat); + }, + $this->components + )); } /** @@ -206,6 +238,12 @@ public function getArrayRepresentation(): array { return [ 'class' => $this->getShortClassName(), + 'components' => \array_map( + static function (Component $component): array { + return $component->getArrayRepresentation(); + }, + $this->components + ), ]; } } diff --git a/tests/Unit/Property/KeyframeSelectorTest.php b/tests/Unit/Property/KeyframeSelectorTest.php index 4533e677..e73bf232 100644 --- a/tests/Unit/Property/KeyframeSelectorTest.php +++ b/tests/Unit/Property/KeyframeSelectorTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Sabberworm\CSS\Property\KeyframeSelector; +use Sabberworm\CSS\Property\Selector\CompoundSelector; /** * @covers \Sabberworm\CSS\Property\KeyframeSelector @@ -24,4 +25,16 @@ public function getArrayRepresentationIncludesClassName(): void self::assertSame('KeyframeSelector', $result['class']); } + + /** + * @test + */ + public function getArrayRepresentationIncludesComponent(): void + { + $subject = new KeyframeSelector([new CompoundSelector('50%')]); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('50%', $result['components'][0]['value']); + } } diff --git a/tests/Unit/Property/SelectorTest.php b/tests/Unit/Property/SelectorTest.php index 58a98fa6..d22190da 100644 --- a/tests/Unit/Property/SelectorTest.php +++ b/tests/Unit/Property/SelectorTest.php @@ -9,6 +9,9 @@ use Sabberworm\CSS\Parsing\ParserState; use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Property\Selector; +use Sabberworm\CSS\Property\Selector\Combinator; +use Sabberworm\CSS\Property\Selector\Component; +use Sabberworm\CSS\Property\Selector\CompoundSelector; use Sabberworm\CSS\Renderable; use Sabberworm\CSS\Settings; use TRegx\PhpUnit\DataProviders\DataProvider; @@ -315,10 +318,11 @@ public function parseExtractsTwoCommentsFromSelector(): void * @test * * @dataProvider provideInvalidSelectors + * @dataProvider provideInvalidSelectorsForParse */ public function constructorThrowsExceptionWithInvalidSelector(string $selector): void { - $this->expectException(\UnexpectedValueException::class); + $this->expectException(UnexpectedTokenException::class); new Selector($selector); } @@ -327,16 +331,141 @@ public function constructorThrowsExceptionWithInvalidSelector(string $selector): * @test * * @dataProvider provideInvalidSelectors + * @dataProvider provideInvalidSelectorsForParse */ public function setSelectorThrowsExceptionWithInvalidSelector(string $selector): void { - $this->expectException(\UnexpectedValueException::class); + $this->expectException(UnexpectedTokenException::class); $subject = new Selector('a'); $subject->setSelector($selector); } + /** + * @return array< + * non-empty-string, + * array{ + * 0: non-empty-list, + * 1: non-empty-list + * } + * > + */ + public static function provideComponentsAndArrayRepresentation(): array + { + return [ + 'simple selector' => [ + [new CompoundSelector('p')], + [ + [ + 'class' => 'CompoundSelector', + 'value' => 'p', + ], + ], + ], + 'selector with combinator' => [ + [ + new CompoundSelector('ul'), + new Combinator('>'), + new CompoundSelector('li'), + ], + [ + [ + 'class' => 'CompoundSelector', + 'value' => 'ul', + ], + [ + 'class' => 'Combinator', + 'value' => '>', + ], + [ + 'class' => 'CompoundSelector', + 'value' => 'li', + ], + ], + ], + ]; + } + + /** + * @test + * + * @param non-empty-list $components + * @param non-empty-list $expectedRepresenation + * + * @dataProvider provideComponentsAndArrayRepresentation + */ + public function constructsWithComponentsProvided(array $components, array $expectedRepresenation): void + { + $subject = new Selector($components); + + $representation = $subject->getArrayRepresentation()['components']; + self::assertSame($expectedRepresenation, $representation); + } + + /** + * @test + */ + public function setComponentsProvidesFluentInterface(): void + { + $subject = new Selector([new CompoundSelector('p')]); + + $result = $subject->setComponents([new CompoundSelector('li')]); + + self::assertSame($subject, $result); + } + + /** + * @test + * + * @param non-empty-list $components + * @param non-empty-list $expectedRepresenation + * + * @dataProvider provideComponentsAndArrayRepresentation + */ + public function setComponentsSetsComponentsProvided(array $components, array $expectedRepresenation): void + { + $subject = new Selector([new CompoundSelector('p')]); + + $subject->setComponents($components); + + $representation = $subject->getArrayRepresentation()['components']; + self::assertSame($expectedRepresenation, $representation); + } + + /** + * @test + * + * @param non-empty-list $components + * + * @dataProvider provideComponentsAndArrayRepresentation + */ + public function getComponentsReturnsComponentsProvidedToConstructor(array $components): void + { + $subject = new Selector($components); + + $result = $subject->getComponents(); + + self::assertSame($components, $result); + } + + /** + * @test + * + * @param non-empty-list $components + * + * @dataProvider provideComponentsAndArrayRepresentation + */ + public function getComponentsReturnsComponentsSet(array $components): void + { + $subject = new Selector([new CompoundSelector('p')]); + $subject->setComponents($components); + + $result = $subject->getComponents(); + + self::assertSame($components, $result); + } + /** * @test * @@ -446,10 +575,22 @@ public function doesNotCleanupSpacesWithinAttributeSelector(): void */ public function getArrayRepresentationIncludesClassName(): void { - $subject = new Selector('a'); + $subject = new Selector([new CompoundSelector('p')]); $result = $subject->getArrayRepresentation(); self::assertSame('Selector', $result['class']); } + + /** + * @test + */ + public function getArrayRepresentationIncludesComponent(): void + { + $subject = new Selector([new CompoundSelector('p.test')]); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('p.test', $result['components'][0]['value']); + } }