Skip to content

Commit 2aab41f

Browse files
committed
[FEATURE] Represent Selector as Component objects
Part of #1325.
1 parent b67aac0 commit 2aab41f

4 files changed

Lines changed: 188 additions & 37 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)
2225
- Clean up extra whitespace in CSS selector (#1398)

src/Property/Selector.php

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,20 @@
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;
16+
use Sabberworm\CSS\ShortClassNameProvider;
1617

1718
use function Safe\preg_match;
18-
use function Safe\preg_replace;
1919

2020
/**
2121
* Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this
2222
* class.
2323
*/
2424
class Selector implements Renderable
2525
{
26+
use ShortClassNameProvider;
27+
2628
/**
2729
* @internal since 8.5.2
2830
*/
@@ -54,9 +56,9 @@ class Selector implements Renderable
5456
/ux';
5557

5658
/**
57-
* @var string
59+
* @var list<Component>
5860
*/
59-
private $selector;
61+
private $components = [];
6062

6163
/**
6264
* @internal since V8.8.0
@@ -72,9 +74,13 @@ public static function isValid(string $selector): bool
7274
/**
7375
* @throws \UnexpectedValueException if the selector is not valid
7476
*/
75-
final public function __construct(string $selector)
77+
final public function __construct(string $selector = '')
7678
{
77-
$this->setSelector($selector);
79+
// Allow construction of empty object for content to be set via `setComponents()`.
80+
// (`setSelector()` will throw an exception when provided with an empty string.)
81+
if ($selector !== '') {
82+
$this->setSelector($selector);
83+
}
7884
}
7985

8086
/**
@@ -137,52 +143,76 @@ public static function parse(ParserState $parserState, array &$comments = []): s
137143
);
138144
}
139145

140-
$selectorString = '';
141-
foreach ($selectorParts as $selectorPart) {
142-
$selectorPartValue = $selectorPart->getValue();
143-
if (\in_array($selectorPartValue, ['>', '+', '~'], true)) {
144-
$selectorString .= ' ' . $selectorPartValue . ' ';
145-
} else {
146-
$selectorString .= $selectorPartValue;
147-
}
148-
}
146+
return (new static())->setComponents($selectorParts);
147+
}
149148

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

153169
public function getSelector(): string
154170
{
155-
return $this->selector;
171+
return $this->render(new OutputFormat());
156172
}
157173

158174
/**
159175
* @throws \UnexpectedValueException if the selector is not valid
160176
*/
161177
public function setSelector(string $selector): void
162178
{
163-
if (!self::isValid($selector)) {
164-
throw new \UnexpectedValueException("Selector `$selector` is not valid.");
165-
}
179+
$parserState = new ParserState($selector, Settings::create());
166180

167-
$selector = \trim($selector);
181+
$components = self::parseComponents($parserState);
168182

169-
$hasAttribute = \strpos($selector, '[') !== false;
183+
// Check that the selector has been fully parsed:
184+
if (!$parserState->isEnd()) {
185+
throw new UnexpectedTokenException(
186+
'EOF',
187+
$parserState->peek(5),
188+
'literal'
189+
);
190+
}
170191

171-
// Whitespace can't be adjusted within an attribute selector, as it would change its meaning
172-
$this->selector = !$hasAttribute ? preg_replace('/\\s++/', ' ', $selector) : $selector;
192+
$this->components = $components;
173193
}
174194

175195
/**
176196
* @return int<0, max>
177197
*/
178198
public function getSpecificity(): int
179199
{
180-
return SpecificityCalculator::calculate($this->selector);
200+
return \array_sum(\array_map(
201+
static function (Component $component): int {
202+
return $component->getSpecificity();
203+
},
204+
$this->components
205+
));
181206
}
182207

183208
public function render(OutputFormat $outputFormat): string
184209
{
185-
return $this->getSelector();
210+
return \implode('', \array_map(
211+
static function (Component $component) use ($outputFormat): string {
212+
return $component->render($outputFormat);
213+
},
214+
$this->components
215+
));
186216
}
187217

188218
/**
@@ -192,6 +222,14 @@ public function render(OutputFormat $outputFormat): string
192222
*/
193223
public function getArrayRepresentation(): array
194224
{
195-
throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
225+
return [
226+
'class' => $this->getShortClassName(),
227+
'components' => \array_map(
228+
static function (Component $component): array {
229+
return $component->getArrayRepresentation();
230+
},
231+
$this->components
232+
),
233+
];
196234
}
197235
}

tests/Unit/Property/KeyframeSelectorTest.php

Lines changed: 17 additions & 4 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
@@ -16,12 +17,24 @@ final class KeyframeSelectorTest extends TestCase
1617
/**
1718
* @test
1819
*/
19-
public function getArrayRepresentationThrowsException(): void
20+
public function getArrayRepresentationIncludesClassName(): void
2021
{
21-
$this->expectException(\BadMethodCallException::class);
22+
$subject = new KeyframeSelector('');
2223

23-
$subject = new KeyframeSelector('a');
24+
$result = $subject->getArrayRepresentation();
2425

25-
$subject->getArrayRepresentation();
26+
self::assertSame('KeyframeSelector', $result['class']);
27+
}
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']);
2639
}
2740
}

tests/Unit/Property/SelectorTest.php

Lines changed: 103 additions & 6 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;
@@ -329,10 +332,17 @@ public function canConstructObjectWithEmptyState(): void
329332
* @test
330333
*
331334
* @dataProvider provideInvalidSelectors
335+
* @dataProvider provideInvalidSelectorsForParse
332336
*/
333337
public function constructorThrowsExceptionWithInvalidSelector(string $selector): void
334338
{
335-
$this->expectException(\UnexpectedValueException::class);
339+
// An empty string is allowed to construct an empty object
340+
if ($selector === '') {
341+
self::expectNotToPerformAssertions();
342+
return;
343+
}
344+
345+
$this->expectException(UnexpectedTokenException::class);
336346

337347
new Selector($selector);
338348
}
@@ -341,16 +351,91 @@ public function constructorThrowsExceptionWithInvalidSelector(string $selector):
341351
* @test
342352
*
343353
* @dataProvider provideInvalidSelectors
354+
* @dataProvider provideInvalidSelectorsForParse
344355
*/
345356
public function setSelectorThrowsExceptionWithInvalidSelector(string $selector): void
346357
{
347-
$this->expectException(\UnexpectedValueException::class);
358+
$this->expectException(UnexpectedTokenException::class);
348359

349360
$subject = new Selector('a');
350361

351362
$subject->setSelector($selector);
352363
}
353364

365+
/**
366+
* @return array<non-empty-string, array{0: list<Component>, 1: list<array{class: string, value: string}>}>
367+
*/
368+
public static function provideComponentsAndArrayRepresentation(): array
369+
{
370+
return [
371+
'simple selector' => [
372+
[new CompoundSelector('p')],
373+
[
374+
[
375+
'class' => 'CompoundSelector',
376+
'value' => 'p',
377+
],
378+
],
379+
],
380+
'selector with combinator' => [
381+
[
382+
new CompoundSelector('ul'),
383+
new Combinator('>'),
384+
new CompoundSelector('li'),
385+
],
386+
[
387+
[
388+
'class' => 'CompoundSelector',
389+
'value' => 'ul',
390+
],
391+
[
392+
'class' => 'Combinator',
393+
'value' => '>',
394+
],
395+
[
396+
'class' => 'CompoundSelector',
397+
'value' => 'li',
398+
],
399+
],
400+
],
401+
];
402+
}
403+
404+
/**
405+
* @test
406+
*
407+
* @param list<Component> $components
408+
* @param list<array{class: string, value: string}> $expectedRepresenation
409+
*
410+
* @dataProvider provideComponentsAndArrayRepresentation
411+
*/
412+
public function setComponentsSetsComponentsProvided(array $components, array $expectedRepresenation): void
413+
{
414+
$subject = new Selector('');
415+
416+
$subject->setComponents($components);
417+
418+
$representation = $subject->getArrayRepresentation()['components'];
419+
self::assertSame($expectedRepresenation, $representation);
420+
}
421+
422+
/**
423+
* @test
424+
*
425+
* @param list<Component> $components
426+
*
427+
* @dataProvider provideComponentsAndArrayRepresentation
428+
*/
429+
public function getComponentsReturnsComponentsSet(array $components): void
430+
{
431+
$subject = new Selector('');
432+
$subject->setComponents($components);
433+
434+
$result = $subject->getComponents();
435+
436+
self::assertSame($components, $result);
437+
}
438+
354439
/**
355440
* @test
356441
*
@@ -458,12 +543,24 @@ public function doesNotCleanupSpacesWithinAttributeSelector(): void
458543
/**
459544
* @test
460545
*/
461-
public function getArrayRepresentationThrowsException(): void
546+
public function getArrayRepresentationIncludesClassName(): void
462547
{
463-
$this->expectException(\BadMethodCallException::class);
548+
$subject = new Selector('');
464549

465-
$subject = new Selector('a');
550+
$result = $subject->getArrayRepresentation();
551+
552+
self::assertSame('Selector', $result['class']);
553+
}
554+
555+
/**
556+
* @test
557+
*/
558+
public function getArrayRepresentationIncludesComponent(): void
559+
{
560+
$subject = (new Selector(''))->setComponents([new CompoundSelector('p.test')]);
561+
562+
$result = $subject->getArrayRepresentation();
466563

467-
$subject->getArrayRepresentation();
564+
self::assertSame('p.test', $result['components'][0]['value']);
468565
}
469566
}

0 commit comments

Comments
 (0)