Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 67 additions & 29 deletions src/Property/Selector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,9 +58,9 @@ class Selector implements Renderable
/ux';

/**
* @var non-empty-string
* @var non-empty-list<Component>
*/
private $selector;
private $components;

/**
* @internal since V8.8.0
Expand All @@ -75,13 +74,18 @@ public static function isValid(string $selector): bool
}

/**
* @param non-empty-string $selector
* @param non-empty-string|non-empty-list<Component> $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);
}
}

/**
Expand Down Expand Up @@ -144,57 +148,85 @@ 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);
Comment thread
oliverklee marked this conversation as resolved.
}

return new static($selectorString);
/**
* @return non-empty-list<Component>
*/
public function getComponents(): array
{
return $this->components;
}

/**
* @param non-empty-list<Component> $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
Comment thread
oliverklee marked this conversation as resolved.
{
$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());
Comment thread
oliverklee marked this conversation as resolved.
}

/**
* @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());
Comment thread
oliverklee marked this conversation as resolved.

$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;
}

/**
* @return int<0, max>
*/
public function getSpecificity(): int
{
return SpecificityCalculator::calculate($this->selector);
Comment thread
oliverklee marked this conversation as resolved.
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
));
}

/**
Expand All @@ -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
),
];
}
}
13 changes: 13 additions & 0 deletions tests/Unit/Property/KeyframeSelectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PHPUnit\Framework\TestCase;
use Sabberworm\CSS\Property\KeyframeSelector;
use Sabberworm\CSS\Property\Selector\CompoundSelector;

/**
* @covers \Sabberworm\CSS\Property\KeyframeSelector
Expand All @@ -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']);
}
}
147 changes: 144 additions & 3 deletions tests/Unit/Property/SelectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<Component>,
* 1: non-empty-list<array{class: non-empty-string, value: non-empty-string}>
* }
* >
*/
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<Component> $components
* @param non-empty-list<array{class: non-empty-string, value: non-empty-string}> $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<Component> $components
* @param non-empty-list<array{class: non-empty-string, value: non-empty-string}> $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<Component> $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<Component> $components
*
* @dataProvider provideComponentsAndArrayRepresentation
*/
public function getComponentsReturnsComponentsSet(array $components): void
Comment thread
oliverklee marked this conversation as resolved.
{
$subject = new Selector([new CompoundSelector('p')]);
$subject->setComponents($components);

$result = $subject->getComponents();

self::assertSame($components, $result);
}

/**
* @test
*
Expand Down Expand Up @@ -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']);
}
}