Skip to content

Commit b724647

Browse files
committed
[TASK] Add Selector\Combinator class
1 parent ba1dd90 commit b724647

2 files changed

Lines changed: 397 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sabberworm\CSS\Property\Selector;
6+
7+
use Sabberworm\CSS\Comment\Comment;
8+
use Sabberworm\CSS\OutputFormat;
9+
use Sabberworm\CSS\Parsing\ParserState;
10+
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
11+
use Sabberworm\CSS\Renderable;
12+
use Sabberworm\CSS\ShortClassNameProvider;
13+
14+
/**
15+
* Class representing a CSS selector combinator (space, `>`, `+`, or `~`).
16+
*/
17+
class Combinator implements Component, Renderable
18+
{
19+
use ShortClassNameProvider;
20+
21+
/**
22+
* @var ' '|'>'|'+'|'~'
23+
*/
24+
private $value;
25+
26+
/**
27+
* @param ' '|'>'|'+'|'~' $value
28+
*/
29+
public function __construct(string $value)
30+
{
31+
$this->setValue($value);
32+
}
33+
34+
/**
35+
* @param list<Comment> $comments
36+
*
37+
* @throws UnexpectedTokenException
38+
*
39+
* @internal
40+
*/
41+
public static function parse(ParserState $parserState, array &$comments = []): self
42+
{
43+
$consumedWhitespace = $parserState->consumeWhiteSpace($comments);
44+
45+
$nextToken = $parserState->peek();
46+
if (\in_array($nextToken, ['>', '+', '~'], true)) {
47+
$value = $nextToken;
48+
$parserState->consume(1);
49+
$parserState->consumeWhiteSpace($comments);
50+
} elseif ($consumedWhitespace !== '') {
51+
$value = ' ';
52+
} else {
53+
throw new UnexpectedTokenException(
54+
'combinator',
55+
$nextToken,
56+
'literal',
57+
$parserState->currentLine()
58+
);
59+
}
60+
61+
return new self($value);
62+
}
63+
64+
/**
65+
* @return ' '|'>'|'+'|'~'
66+
*/
67+
public function getValue(): string
68+
{
69+
return $this->value;
70+
}
71+
72+
/**
73+
* @param non-empty-string $value
74+
*
75+
* @throws \UnexpectedValueException
76+
*/
77+
public function setValue(string $value): void
78+
{
79+
// Allow extra and other whitespace even if not publicly documented.
80+
$trimmedValue = \trim($value);
81+
82+
// But reject anything else that is not a combinator.
83+
if (!\in_array($trimmedValue, ['', '>', '+', '~'], true)) {
84+
throw new \UnexpectedValueException('`' . $trimmedValue . '` is not a valid selector combinator.');
85+
}
86+
87+
$this->value = $trimmedValue !== '' ? $trimmedValue : ' ';
88+
}
89+
90+
/**
91+
* @return int<0, max>
92+
*/
93+
public function getSpecificity(): int
94+
{
95+
return 0;
96+
}
97+
98+
public function render(OutputFormat $outputFormat): string
99+
{
100+
// TODO: allow optional spacing controlled via OutputFormat
101+
return $this->getValue();
102+
}
103+
104+
/**
105+
* @return array<string, bool|int|float|string|array<mixed>|null>
106+
*
107+
* @internal
108+
*/
109+
public function getArrayRepresentation(): array
110+
{
111+
return [
112+
'class' => $this->getShortClassName(),
113+
'value' => $this->value,
114+
];
115+
}
116+
}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sabberworm\CSS\Tests\Unit\Property\Selector;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Sabberworm\CSS\OutputFormat;
9+
use Sabberworm\CSS\Parsing\ParserState;
10+
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
11+
use Sabberworm\CSS\Property\Selector\Component;
12+
use Sabberworm\CSS\Property\Selector\Combinator;
13+
use Sabberworm\CSS\Renderable;
14+
use Sabberworm\CSS\Settings;
15+
16+
/**
17+
* @covers \Sabberworm\CSS\Property\Selector\Combinator
18+
*/
19+
final class CombinatorTest extends TestCase
20+
{
21+
/**
22+
* @test
23+
*/
24+
public function implementsRenderable(): void
25+
{
26+
self::assertInstanceOf(Renderable::class, new Combinator('>'));
27+
}
28+
29+
/**
30+
* @test
31+
*/
32+
public function implementsSelectorComponent(): void
33+
{
34+
self::assertInstanceOf(Component::class, new Combinator('>'));
35+
}
36+
37+
/**
38+
* @return array<non-empty-string, array{0: ' '|'>'|'+'|'~'}>
39+
*/
40+
public static function provideValidValue(): array
41+
{
42+
return [
43+
'descendent' => [' '],
44+
'child' => ['>'],
45+
'next sibling' => ['+'],
46+
'subsequent sibling' => ['~'],
47+
];
48+
}
49+
50+
/**
51+
* @return array<non-empty-string, array{0: non-empty-string}>
52+
*/
53+
public static function provideInvalidValue(): array
54+
{
55+
return [
56+
'other symbol' => ['@'],
57+
'uppercase letter' => ['Z'],
58+
'lowercase letter' => ['j'],
59+
'number' => ['1'],
60+
'sequence' => ['abc?123'],
61+
];
62+
}
63+
64+
/**
65+
* @test
66+
*
67+
* @param ' '|'>'|'+'|'~' $combinator
68+
*
69+
* @dataProvider provideValidValue
70+
*/
71+
public function parsesValidCombinator(string $combinator): void
72+
{
73+
$result = Combinator::parse(new ParserState($combinator, Settings::create()));
74+
75+
self::assertSame($combinator, $result->getArrayRepresentation()['value']);
76+
}
77+
78+
/**
79+
* @test
80+
*
81+
* @param non-empty-string $selectorComponent
82+
*
83+
* @dataProvider provideInvalidValue
84+
*/
85+
public function parseThrowsExceptionWithInvalidCombinator(string $selectorComponent): void
86+
{
87+
$this->expectException(UnexpectedTokenException::class);
88+
89+
Combinator::parse(new ParserState($selectorComponent, Settings::create()));
90+
}
91+
92+
/**
93+
* @test
94+
*
95+
* @param ' '|'>'|'+'|'~' $combinator
96+
*
97+
* @dataProvider provideValidValue
98+
*/
99+
public function parsesCombinatorWithCommentBefore(string $combinator): void
100+
{
101+
$result = Combinator::parse(new ParserState('/*comment*/' . $combinator, Settings::create()));
102+
103+
self::assertSame($combinator, $result->getArrayRepresentation()['value']);
104+
}
105+
106+
/**
107+
* @test
108+
*
109+
* @param ' '|'>'|'+'|'~' $combinator
110+
*
111+
* @dataProvider provideValidValue
112+
*/
113+
public function parsesCombinatorWithCommentAfter(string $combinator): void
114+
{
115+
$result = Combinator::parse(new ParserState($combinator . '/*comment*/', Settings::create()));
116+
117+
self::assertSame($combinator, $result->getArrayRepresentation()['value']);
118+
}
119+
120+
/**
121+
* @test
122+
*
123+
* @param ' '|'>'|'+'|'~' $combinator
124+
*
125+
* @dataProvider provideValidValue
126+
*/
127+
public function parseExtractsCommentBefore(string $combinator): void
128+
{
129+
$result = [];
130+
Combinator::parse(new ParserState('/*comment*/' . $combinator, Settings::create()), $result);
131+
132+
self::assertSame('comment', $result[0]->getArrayRepresentation()['contents']);
133+
}
134+
135+
/**
136+
* @test
137+
*
138+
* @param ' '|'>'|'+'|'~' $combinator
139+
*
140+
* @dataProvider provideValidValue
141+
*/
142+
public function parseExtractsCommentAfter(string $combinator): void
143+
{
144+
$result = [];
145+
Combinator::parse(new ParserState($combinator . '/*comment*/', Settings::create()), $result);
146+
147+
self::assertSame('comment', $result[0]->getArrayRepresentation()['contents']);
148+
}
149+
150+
/**
151+
* @test
152+
*
153+
* @param ' '|'>'|'+'|'~' $value
154+
*
155+
* @dataProvider provideValidValue
156+
*/
157+
public function constructsWithValueProvided(string $value): void
158+
{
159+
$subject = new Combinator($value);
160+
161+
self::assertSame($value, $subject->getArrayRepresentation()['value']);
162+
}
163+
164+
/**
165+
* @test
166+
*
167+
* @param non-empty-string $value
168+
*
169+
* @dataProvider provideInvalidValue
170+
*/
171+
public function constructorThrowsExceptionWithInvalidValue(string $value): void
172+
{
173+
$this->expectException(\UnexpectedValueException::class);
174+
175+
new Combinator($value);
176+
}
177+
178+
/**
179+
* @test
180+
*
181+
* @param ' '|'>'|'+'|'~' $value
182+
*
183+
* @dataProvider provideValidValue
184+
*/
185+
public function setsValueProvided(string $value): void
186+
{
187+
$subject = new Combinator('>');
188+
189+
$subject->setValue($value);
190+
191+
self::assertSame($value, $subject->getArrayRepresentation()['value']);
192+
}
193+
194+
/**
195+
* @test
196+
*
197+
* @param non-empty-string $value
198+
*
199+
* @dataProvider provideInvalidValue
200+
*/
201+
public function setValueThrowsExceptionWithInvalidValue(string $value): void
202+
{
203+
$this->expectException(\UnexpectedValueException::class);
204+
205+
$subject = new Combinator('>');
206+
207+
$subject->setValue($value);
208+
}
209+
210+
/**
211+
* @test
212+
*
213+
* @param ' '|'>'|'+'|'~' $value
214+
*
215+
* @dataProvider provideValidValue
216+
*/
217+
public function getValueReturnsValueProvided(string $value): void
218+
{
219+
$subject = new Combinator($value);
220+
221+
$result = $subject->getValue();
222+
223+
self::assertSame($value, $result);
224+
}
225+
226+
/**
227+
* @test
228+
*
229+
* @param ' '|'>'|'+'|'~' $value
230+
*
231+
* @dataProvider provideValidValue
232+
*/
233+
public function hasNoSpecificity(string $value): void
234+
{
235+
$subject = new Combinator($value);
236+
237+
self::assertSame(0, $subject->getSpecificity());
238+
}
239+
240+
/**
241+
* @test
242+
*
243+
* @param ' '|'>'|'+'|'~' $value
244+
*
245+
* @dataProvider provideValidValue
246+
*/
247+
public function rendersValueProvided(string $value): void
248+
{
249+
$subject = new Combinator($value);
250+
251+
self::assertSame($value, $subject->render(OutputFormat::create()));
252+
}
253+
254+
/**
255+
* @test
256+
*/
257+
public function getArrayRepresentationIncludesClassName(): void
258+
{
259+
$subject = new Combinator('>');
260+
261+
$result = $subject->getArrayRepresentation();
262+
263+
self::assertSame('Combinator', $result['class']);
264+
}
265+
266+
/**
267+
* @test
268+
*
269+
* @param ' '|'>'|'+'|'~' $value
270+
*
271+
* @dataProvider provideValidValue
272+
*/
273+
public function getArrayRepresentationIncludesValue(string $value): void
274+
{
275+
$subject = new Combinator($value);
276+
277+
$result = $subject->getArrayRepresentation();
278+
279+
self::assertSame($value, $result['value']);
280+
}
281+
}

0 commit comments

Comments
 (0)