Skip to content

Commit 8e87b8a

Browse files
authored
[FEATURE] Represent Selector as Component objects (#1496)
Part of #1325.
1 parent dd1b3de commit 8e87b8a

4 files changed

Lines changed: 227 additions & 32 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: 67 additions & 29 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 non-empty-list<Component>
6362
*/
64-
private $selector;
63+
private $components;
6564

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

7776
/**
78-
* @param non-empty-string $selector
77+
* @param non-empty-string|non-empty-list<Component> $selector
78+
* Providing a string is deprecated in version 9.2 and will not work from v10.0
7979
*
80-
* @throws \UnexpectedValueException if the selector is not valid
80+
* @throws UnexpectedTokenException if the selector is not valid
8181
*/
82-
final public function __construct(string $selector)
82+
final public function __construct($selector)
8383
{
84-
$this->setSelector($selector);
84+
if (\is_string($selector)) {
85+
$this->setSelector($selector);
86+
} else {
87+
$this->setComponents($selector);
88+
}
8589
}
8690

8791
/**
@@ -144,57 +148,85 @@ public static function parse(ParserState $parserState, array &$comments = []): s
144148
);
145149
}
146150

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-
}
151+
return new static($selectorParts);
152+
}
156153

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

160174
/**
161175
* @return non-empty-string
176+
*
177+
* @deprecated in version 9.2, will be removed in v10.0. Use either `getComponents()` or `render()` instead.
162178
*/
163179
public function getSelector(): string
164180
{
165-
return $this->selector;
181+
return $this->render(new OutputFormat());
166182
}
167183

168184
/**
169185
* @param non-empty-string $selector
170186
*
171-
* @throws \UnexpectedValueException if the selector is not valid
187+
* @throws UnexpectedTokenException if the selector is not valid
188+
*
189+
* @deprecated in version 9.2, will be removed in v10.0. Use `setComponents()` instead.
172190
*/
173191
public function setSelector(string $selector): void
174192
{
175-
if (!self::isValid($selector)) {
176-
throw new \UnexpectedValueException("Selector `$selector` is not valid.");
177-
}
193+
$parserState = new ParserState($selector, Settings::create());
178194

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

181-
$hasAttribute = \strpos($selector, '[') !== false;
197+
// Check that the selector has been fully parsed:
198+
if (!$parserState->isEnd()) {
199+
throw new UnexpectedTokenException(
200+
'EOF',
201+
$parserState->peek(5),
202+
'literal'
203+
);
204+
}
182205

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;
206+
$this->components = $components;
185207
}
186208

187209
/**
188210
* @return int<0, max>
189211
*/
190212
public function getSpecificity(): int
191213
{
192-
return SpecificityCalculator::calculate($this->selector);
214+
return \array_sum(\array_map(
215+
static function (Component $component): int {
216+
return $component->getSpecificity();
217+
},
218+
$this->components
219+
));
193220
}
194221

195222
public function render(OutputFormat $outputFormat): string
196223
{
197-
return $this->getSelector();
224+
return \implode('', \array_map(
225+
static function (Component $component) use ($outputFormat): string {
226+
return $component->render($outputFormat);
227+
},
228+
$this->components
229+
));
198230
}
199231

200232
/**
@@ -206,6 +238,12 @@ public function getArrayRepresentation(): array
206238
{
207239
return [
208240
'class' => $this->getShortClassName(),
241+
'components' => \array_map(
242+
static function (Component $component): array {
243+
return $component->getArrayRepresentation();
244+
},
245+
$this->components
246+
),
209247
];
210248
}
211249
}

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([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: 144 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;
@@ -315,10 +318,11 @@ public function parseExtractsTwoCommentsFromSelector(): void
315318
* @test
316319
*
317320
* @dataProvider provideInvalidSelectors
321+
* @dataProvider provideInvalidSelectorsForParse
318322
*/
319323
public function constructorThrowsExceptionWithInvalidSelector(string $selector): void
320324
{
321-
$this->expectException(\UnexpectedValueException::class);
325+
$this->expectException(UnexpectedTokenException::class);
322326

323327
new Selector($selector);
324328
}
@@ -327,16 +331,141 @@ public function constructorThrowsExceptionWithInvalidSelector(string $selector):
327331
* @test
328332
*
329333
* @dataProvider provideInvalidSelectors
334+
* @dataProvider provideInvalidSelectorsForParse
330335
*/
331336
public function setSelectorThrowsExceptionWithInvalidSelector(string $selector): void
332337
{
333-
$this->expectException(\UnexpectedValueException::class);
338+
$this->expectException(UnexpectedTokenException::class);
334339

335340
$subject = new Selector('a');
336341

337342
$subject->setSelector($selector);
338343
}
339344

345+
/**
346+
* @return array<
347+
* non-empty-string,
348+
* array{
349+
* 0: non-empty-list<Component>,
350+
* 1: non-empty-list<array{class: non-empty-string, value: non-empty-string}>
351+
* }
352+
* >
353+
*/
354+
public static function provideComponentsAndArrayRepresentation(): array
355+
{
356+
return [
357+
'simple selector' => [
358+
[new CompoundSelector('p')],
359+
[
360+
[
361+
'class' => 'CompoundSelector',
362+
'value' => 'p',
363+
],
364+
],
365+
],
366+
'selector with combinator' => [
367+
[
368+
new CompoundSelector('ul'),
369+
new Combinator('>'),
370+
new CompoundSelector('li'),
371+
],
372+
[
373+
[
374+
'class' => 'CompoundSelector',
375+
'value' => 'ul',
376+
],
377+
[
378+
'class' => 'Combinator',
379+
'value' => '>',
380+
],
381+
[
382+
'class' => 'CompoundSelector',
383+
'value' => 'li',
384+
],
385+
],
386+
],
387+
];
388+
}
389+
390+
/**
391+
* @test
392+
*
393+
* @param non-empty-list<Component> $components
394+
* @param non-empty-list<array{class: non-empty-string, value: non-empty-string}> $expectedRepresenation
395+
*
396+
* @dataProvider provideComponentsAndArrayRepresentation
397+
*/
398+
public function constructsWithComponentsProvided(array $components, array $expectedRepresenation): void
399+
{
400+
$subject = new Selector($components);
401+
402+
$representation = $subject->getArrayRepresentation()['components'];
403+
self::assertSame($expectedRepresenation, $representation);
404+
}
405+
406+
/**
407+
* @test
408+
*/
409+
public function setComponentsProvidesFluentInterface(): void
410+
{
411+
$subject = new Selector([new CompoundSelector('p')]);
412+
413+
$result = $subject->setComponents([new CompoundSelector('li')]);
414+
415+
self::assertSame($subject, $result);
416+
}
417+
418+
/**
419+
* @test
420+
*
421+
* @param non-empty-list<Component> $components
422+
* @param non-empty-list<array{class: non-empty-string, value: non-empty-string}> $expectedRepresenation
423+
*
424+
* @dataProvider provideComponentsAndArrayRepresentation
425+
*/
426+
public function setComponentsSetsComponentsProvided(array $components, array $expectedRepresenation): void
427+
{
428+
$subject = new Selector([new CompoundSelector('p')]);
429+
430+
$subject->setComponents($components);
431+
432+
$representation = $subject->getArrayRepresentation()['components'];
433+
self::assertSame($expectedRepresenation, $representation);
434+
}
435+
436+
/**
437+
* @test
438+
*
439+
* @param non-empty-list<Component> $components
440+
*
441+
* @dataProvider provideComponentsAndArrayRepresentation
442+
*/
443+
public function getComponentsReturnsComponentsProvidedToConstructor(array $components): void
444+
{
445+
$subject = new Selector($components);
446+
447+
$result = $subject->getComponents();
448+
449+
self::assertSame($components, $result);
450+
}
451+
452+
/**
453+
* @test
454+
*
455+
* @param non-empty-list<Component> $components
456+
*
457+
* @dataProvider provideComponentsAndArrayRepresentation
458+
*/
459+
public function getComponentsReturnsComponentsSet(array $components): void
460+
{
461+
$subject = new Selector([new CompoundSelector('p')]);
462+
$subject->setComponents($components);
463+
464+
$result = $subject->getComponents();
465+
466+
self::assertSame($components, $result);
467+
}
468+
340469
/**
341470
* @test
342471
*
@@ -446,10 +575,22 @@ public function doesNotCleanupSpacesWithinAttributeSelector(): void
446575
*/
447576
public function getArrayRepresentationIncludesClassName(): void
448577
{
449-
$subject = new Selector('a');
578+
$subject = new Selector([new CompoundSelector('p')]);
450579

451580
$result = $subject->getArrayRepresentation();
452581

453582
self::assertSame('Selector', $result['class']);
454583
}
584+
585+
/**
586+
* @test
587+
*/
588+
public function getArrayRepresentationIncludesComponent(): void
589+
{
590+
$subject = new Selector([new CompoundSelector('p.test')]);
591+
592+
$result = $subject->getArrayRepresentation();
593+
594+
self::assertSame('p.test', $result['components'][0]['value']);
595+
}
455596
}

0 commit comments

Comments
 (0)