Skip to content

Commit 4d287a3

Browse files
committed
[FEATURE] Represent Selector as Component objects
Part of #1325.
1 parent e57672c commit 4d287a3

4 files changed

Lines changed: 175 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ Please also have a look at our
1717

1818
### Changed
1919

20+
- `Selector` is now represented as a sequence of `Selector\Component` objects
21+
which can be accessed via `getComponents()`, manipulated individually, or set
22+
via `setComponents()` (#1478, #1486, #1487, #1488, #1494, #1496)
2023
- `Selector::setSelector()` and `Selector` constructor will now throw exception
2124
upon provision of an invalid selectior (#1498, #1502)
2225
- Clean up extra whitespace in CSS selector (#1398)

src/Property/Selector.php

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@
1111
use Sabberworm\CSS\Property\Selector\Combinator;
1212
use Sabberworm\CSS\Property\Selector\Component;
1313
use Sabberworm\CSS\Property\Selector\CompoundSelector;
14-
use Sabberworm\CSS\Property\Selector\SpecificityCalculator;
1514
use Sabberworm\CSS\Renderable;
15+
use Sabberworm\CSS\Settings;
1616
use Sabberworm\CSS\ShortClassNameProvider;
1717

1818
use function Safe\preg_match;
19-
use function Safe\preg_replace;
2019

2120
/**
2221
* 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
5958
/ux';
6059

6160
/**
62-
* @var non-empty-string
61+
* @var list<Component>
6362
*/
64-
private $selector;
63+
private $components = [];
6564

6665
/**
6766
* @internal since V8.8.0
@@ -79,9 +78,13 @@ public static function isValid(string $selector): bool
7978
*
8079
* @throws \UnexpectedValueException if the selector is not valid
8180
*/
82-
final public function __construct(string $selector)
81+
final public function __construct(string $selector = '')
8382
{
84-
$this->setSelector($selector);
83+
// Allow construction of empty object for content to be set via `setComponents()`.
84+
// (`setSelector()` will throw an exception when provided with an empty string.)
85+
if ($selector !== '') {
86+
$this->setSelector($selector);
87+
}
8588
}
8689

8790
/**
@@ -144,25 +147,35 @@ public static function parse(ParserState $parserState, array &$comments = []): s
144147
);
145148
}
146149

147-
$selectorString = '';
148-
foreach ($selectorParts as $selectorPart) {
149-
$selectorPartValue = $selectorPart->getValue();
150-
if (\in_array($selectorPartValue, ['>', '+', '~'], true)) {
151-
$selectorString .= ' ' . $selectorPartValue . ' ';
152-
} else {
153-
$selectorString .= $selectorPartValue;
154-
}
155-
}
150+
return (new static())->setComponents($selectorParts);
151+
}
156152

157-
return new static($selectorString);
153+
/**
154+
* @return list<Component>
155+
*/
156+
public function getComponents(): array
157+
{
158+
return $this->components;
159+
}
160+
161+
/**
162+
* @param list<Component> $components
163+
* This should be an alternating sequence of `CompoundSelector` and `Combinator`, starting and ending with a
164+
* `CompoundSelector`, and may be a single `CompoundSelector`.
165+
*/
166+
public function setComponents(array $components): self
167+
{
168+
$this->components = $components;
169+
170+
return $this;
158171
}
159172

160173
/**
161174
* @return non-empty-string
162175
*/
163176
public function getSelector(): string
164177
{
165-
return $this->selector;
178+
return $this->render(new OutputFormat());
166179
}
167180

168181
/**
@@ -172,29 +185,43 @@ public function getSelector(): string
172185
*/
173186
public function setSelector(string $selector): void
174187
{
175-
if (!self::isValid($selector)) {
176-
throw new \UnexpectedValueException("Selector `$selector` is not valid.");
177-
}
188+
$parserState = new ParserState($selector, Settings::create());
178189

179-
$selector = \trim($selector);
190+
$components = self::parseComponents($parserState);
180191

181-
$hasAttribute = \strpos($selector, '[') !== false;
192+
// Check that the selector has been fully parsed:
193+
if (!$parserState->isEnd()) {
194+
throw new UnexpectedTokenException(
195+
'EOF',
196+
$parserState->peek(5),
197+
'literal'
198+
);
199+
}
182200

183-
// Whitespace can't be adjusted within an attribute selector, as it would change its meaning
184-
$this->selector = !$hasAttribute ? preg_replace('/\\s++/', ' ', $selector) : $selector;
201+
$this->components = $components;
185202
}
186203

187204
/**
188205
* @return int<0, max>
189206
*/
190207
public function getSpecificity(): int
191208
{
192-
return SpecificityCalculator::calculate($this->selector);
209+
return \array_sum(\array_map(
210+
static function (Component $component): int {
211+
return $component->getSpecificity();
212+
},
213+
$this->components
214+
));
193215
}
194216

195217
public function render(OutputFormat $outputFormat): string
196218
{
197-
return $this->getSelector();
219+
return \implode('', \array_map(
220+
static function (Component $component) use ($outputFormat): string {
221+
return $component->render($outputFormat);
222+
},
223+
$this->components
224+
));
198225
}
199226

200227
/**
@@ -206,6 +233,12 @@ public function getArrayRepresentation(): array
206233
{
207234
return [
208235
'class' => $this->getShortClassName(),
236+
'components' => \array_map(
237+
static function (Component $component): array {
238+
return $component->getArrayRepresentation();
239+
},
240+
$this->components
241+
),
209242
];
210243
}
211244
}

tests/Unit/Property/KeyframeSelectorTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use PHPUnit\Framework\TestCase;
88
use Sabberworm\CSS\Property\KeyframeSelector;
9+
use Sabberworm\CSS\Property\Selector\CompoundSelector;
910

1011
/**
1112
* @covers \Sabberworm\CSS\Property\KeyframeSelector
@@ -24,4 +25,16 @@ public function getArrayRepresentationIncludesClassName(): void
2425

2526
self::assertSame('KeyframeSelector', $result['class']);
2627
}
28+
29+
/**
30+
* @test
31+
*/
32+
public function getArrayRepresentationIncludesComponent(): void
33+
{
34+
$subject = (new KeyframeSelector())->setComponents([new CompoundSelector('50%')]);
35+
36+
$result = $subject->getArrayRepresentation();
37+
38+
self::assertSame('50%', $result['components'][0]['value']);
39+
}
2740
}

tests/Unit/Property/SelectorTest.php

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
use Sabberworm\CSS\Parsing\ParserState;
1010
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
1111
use Sabberworm\CSS\Property\Selector;
12+
use Sabberworm\CSS\Property\Selector\Combinator;
13+
use Sabberworm\CSS\Property\Selector\Component;
14+
use Sabberworm\CSS\Property\Selector\CompoundSelector;
1215
use Sabberworm\CSS\Renderable;
1316
use Sabberworm\CSS\Settings;
1417
use TRegx\PhpUnit\DataProviders\DataProvider;
@@ -317,10 +320,17 @@ public function parseExtractsTwoCommentsFromSelector(): void
317320
* @test
318321
*
319322
* @dataProvider provideInvalidSelectors
323+
* @dataProvider provideInvalidSelectorsForParse
320324
*/
321325
public function constructorThrowsExceptionWithInvalidSelector(string $selector): void
322326
{
323-
$this->expectException(\UnexpectedValueException::class);
327+
// An empty string is allowed to construct an empty object
328+
if ($selector === '') {
329+
self::expectNotToPerformAssertions();
330+
return;
331+
}
332+
333+
$this->expectException(UnexpectedTokenException::class);
324334

325335
new Selector($selector);
326336
}
@@ -329,16 +339,91 @@ public function constructorThrowsExceptionWithInvalidSelector(string $selector):
329339
* @test
330340
*
331341
* @dataProvider provideInvalidSelectors
342+
* @dataProvider provideInvalidSelectorsForParse
332343
*/
333344
public function setSelectorThrowsExceptionWithInvalidSelector(string $selector): void
334345
{
335-
$this->expectException(\UnexpectedValueException::class);
346+
$this->expectException(UnexpectedTokenException::class);
336347

337348
$subject = new Selector('a');
338349

339350
$subject->setSelector($selector);
340351
}
341352

353+
/**
354+
* @return array<non-empty-string, array{0: list<Component>, 1: list<array{class: string, value: string}>}>
355+
*/
356+
public static function provideComponentsAndArrayRepresentation(): array
357+
{
358+
return [
359+
'simple selector' => [
360+
[new CompoundSelector('p')],
361+
[
362+
[
363+
'class' => 'CompoundSelector',
364+
'value' => 'p',
365+
],
366+
],
367+
],
368+
'selector with combinator' => [
369+
[
370+
new CompoundSelector('ul'),
371+
new Combinator('>'),
372+
new CompoundSelector('li'),
373+
],
374+
[
375+
[
376+
'class' => 'CompoundSelector',
377+
'value' => 'ul',
378+
],
379+
[
380+
'class' => 'Combinator',
381+
'value' => '>',
382+
],
383+
[
384+
'class' => 'CompoundSelector',
385+
'value' => 'li',
386+
],
387+
],
388+
],
389+
];
390+
}
391+
392+
/**
393+
* @test
394+
*
395+
* @param list<Component> $components
396+
* @param list<array{class: string, value: string}> $expectedRepresenation
397+
*
398+
* @dataProvider provideComponentsAndArrayRepresentation
399+
*/
400+
public function setComponentsSetsComponentsProvided(array $components, array $expectedRepresenation): void
401+
{
402+
$subject = new Selector();
403+
404+
$subject->setComponents($components);
405+
406+
$representation = $subject->getArrayRepresentation()['components'];
407+
self::assertSame($expectedRepresenation, $representation);
408+
}
409+
410+
/**
411+
* @test
412+
*
413+
* @param list<Component> $components
414+
*
415+
* @dataProvider provideComponentsAndArrayRepresentation
416+
*/
417+
public function getComponentsReturnsComponentsSet(array $components): void
418+
{
419+
$subject = new Selector();
420+
$subject->setComponents($components);
421+
422+
$result = $subject->getComponents();
423+
424+
self::assertSame($components, $result);
425+
}
426+
342427
/**
343428
* @test
344429
*
@@ -448,10 +533,22 @@ public function doesNotCleanupSpacesWithinAttributeSelector(): void
448533
*/
449534
public function getArrayRepresentationIncludesClassName(): void
450535
{
451-
$subject = new Selector('a');
536+
$subject = new Selector();
452537

453538
$result = $subject->getArrayRepresentation();
454539

455540
self::assertSame('Selector', $result['class']);
456541
}
542+
543+
/**
544+
* @test
545+
*/
546+
public function getArrayRepresentationIncludesComponent(): void
547+
{
548+
$subject = (new Selector())->setComponents([new CompoundSelector('p.test')]);
549+
550+
$result = $subject->getArrayRepresentation();
551+
552+
self::assertSame('p.test', $result['components'][0]['value']);
553+
}
457554
}

0 commit comments

Comments
 (0)