Skip to content

Commit 104c487

Browse files
committed
[TASK] Add Selector::parse()
The implementation is based on `DeclarationBlock::parseSelector()`, which will be updated to use this instead in #1470. Part of #1325.
1 parent 7983af6 commit 104c487

2 files changed

Lines changed: 258 additions & 1 deletion

File tree

src/Property/Selector.php

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
namespace Sabberworm\CSS\Property;
66

7+
use Sabberworm\CSS\Comment\Comment;
78
use Sabberworm\CSS\OutputFormat;
9+
use Sabberworm\CSS\Parsing\ParserState;
10+
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
811
use Sabberworm\CSS\Property\Selector\SpecificityCalculator;
912
use Sabberworm\CSS\Renderable;
1013

@@ -63,11 +66,105 @@ public static function isValid(string $selector): bool
6366
return $numberOfMatches === 1;
6467
}
6568

66-
public function __construct(string $selector)
69+
final public function __construct(string $selector)
6770
{
6871
$this->setSelector($selector);
6972
}
7073

74+
/**
75+
* @param list<Comment> $comments
76+
*
77+
* @throws UnexpectedTokenException
78+
*
79+
* @internal
80+
*/
81+
public static function parse(ParserState $parserState, array &$comments = []): self
82+
{
83+
$selectorParts = [];
84+
$stringWrapperCharacter = null;
85+
$functionNestingLevel = 0;
86+
static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',', ParserState::EOF, ''];
87+
88+
while (true) {
89+
$selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
90+
$nextCharacter = $parserState->peek();
91+
switch ($nextCharacter) {
92+
case '':
93+
// EOF
94+
break 2;
95+
case '\'':
96+
// The fallthrough is intentional.
97+
case '"':
98+
if (!\is_string($stringWrapperCharacter)) {
99+
$stringWrapperCharacter = $nextCharacter;
100+
} elseif ($stringWrapperCharacter === $nextCharacter) {
101+
if (\substr(\end($selectorParts), -1) !== '\\') {
102+
$stringWrapperCharacter = null;
103+
}
104+
}
105+
break;
106+
case '(':
107+
if (!\is_string($stringWrapperCharacter)) {
108+
++$functionNestingLevel;
109+
}
110+
break;
111+
case ')':
112+
if (!\is_string($stringWrapperCharacter)) {
113+
if ($functionNestingLevel <= 0) {
114+
throw new UnexpectedTokenException(
115+
'anything but',
116+
')',
117+
'literal',
118+
$parserState->currentLine()
119+
);
120+
}
121+
--$functionNestingLevel;
122+
}
123+
break;
124+
case ',':
125+
if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) {
126+
break 2;
127+
}
128+
break;
129+
case '{':
130+
// The fallthrough is intentional.
131+
case '}':
132+
if (!\is_string($stringWrapperCharacter)) {
133+
break 2;
134+
}
135+
break;
136+
}
137+
$selectorParts[] = $parserState->consume(1);
138+
}
139+
140+
if ($functionNestingLevel !== 0) {
141+
throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
142+
}
143+
if (\is_string($stringWrapperCharacter)) {
144+
throw new UnexpectedTokenException(
145+
$stringWrapperCharacter,
146+
$nextCharacter,
147+
'literal',
148+
$parserState->currentLine()
149+
);
150+
}
151+
152+
$selector = \trim(\implode('', $selectorParts));
153+
if ($selector === '') {
154+
throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
155+
}
156+
if (!self::isValid($selector)) {
157+
throw new UnexpectedTokenException(
158+
"Selector did not match '" . static::SELECTOR_VALIDATION_RX . "'.",
159+
$selector,
160+
'custom',
161+
$parserState->currentLine()
162+
);
163+
}
164+
165+
return new static($selector);
166+
}
167+
71168
public function getSelector(): string
72169
{
73170
return $this->selector;

tests/Unit/Property/SelectorTest.php

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
55
namespace Sabberworm\CSS\Tests\Unit\Property;
66

77
use PHPUnit\Framework\TestCase;
8+
use Sabberworm\CSS\Comment\Comment;
9+
use Sabberworm\CSS\Parsing\ParserState;
10+
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
811
use Sabberworm\CSS\Property\Selector;
912
use Sabberworm\CSS\Renderable;
13+
use Sabberworm\CSS\Settings;
14+
use TRegx\PhpUnit\DataProviders\DataProvider;
1015

1116
/**
1217
* @covers \Sabberworm\CSS\Property\Selector
@@ -73,6 +78,21 @@ public static function provideSelectorsAndSpecificities(): array
7378
];
7479
}
7580

81+
/**
82+
* @test
83+
*
84+
* @param non-empty-string $selector
85+
*
86+
* @dataProvider provideSelectorsAndSpecificities
87+
*/
88+
public function parsesValidSelector(string $selector): void
89+
{
90+
$result = Selector::parse(new ParserState($selector, Settings::create()));
91+
92+
self::assertInstanceOf(Selector::class, $result);
93+
self::assertSame($result->getSelector(), $selector);
94+
}
95+
7696
/**
7797
* @return array<non-empty-string, array{0: string}>
7898
*/
@@ -93,6 +113,146 @@ public static function provideInvalidSelectors(): array
93113
];
94114
}
95115

116+
/**
117+
* @return array<non-empty-string, array{0: string}>
118+
*/
119+
public static function provideInvalidSelectorsForParse(): array
120+
{
121+
return [
122+
'empty string' => [''],
123+
'whitespace only' => [" \t\n\r"],
124+
'not missing closing brace' => [':not(a'],
125+
'not missing opening brace' => [':not a)'],
126+
'attribute value missing closing single quote' => ['a[href=\'#top]'],
127+
'attribute value missing closing double quote' => ['a[href="#top]'],
128+
];
129+
}
130+
131+
/**
132+
* @test
133+
*
134+
* @param non-empty-string $selector
135+
*
136+
* @dataProvider provideInvalidSelectors
137+
* @dataProvider provideInvalidSelectorsForParse
138+
*/
139+
public function parseThrowsExceptionWithInvalidSelector(string $selector): void
140+
{
141+
$this->expectException(UnexpectedTokenException::class);
142+
143+
Selector::parse(new ParserState($selector, Settings::create()));
144+
}
145+
146+
/**
147+
* @return array<non-empty-string, array{0: non-empty-string}>
148+
*/
149+
public static function provideStopCharacters(): array
150+
{
151+
return [
152+
',' => ['{'],
153+
'{' => ['{'],
154+
'}' => ['}'],
155+
];
156+
}
157+
158+
/**
159+
* @return DataProvider<non-empty-string, array{0: non-empty-string, 1: non-empty-string}>
160+
*/
161+
public function provideStopCharactersAndValidSelectors(): DataProvider
162+
{
163+
return DataProvider::cross(self::provideStopCharacters(), self::provideSelectorsAndSpecificities());
164+
}
165+
166+
/**
167+
* @test
168+
*
169+
* @param non-empty-string $stopCharacter
170+
* @param non-empty-string $selector
171+
*
172+
* @dataProvider provideStopCharactersAndValidSelectors
173+
*/
174+
public function parseDoesNotConsumeStopCharacter(string $stopCharacter, string $selector): void
175+
{
176+
$subject = new ParserState($selector . $stopCharacter, Settings::create());
177+
178+
Selector::parse($subject);
179+
180+
self::assertSame($stopCharacter, $subject->peek());
181+
}
182+
183+
/**
184+
* @return array<non-empty-string, array{0: non-empty-string, 1: non-empty-string}>
185+
*/
186+
public static function provideSelectorsWithAndWithoutComment(): array
187+
{
188+
return [
189+
'comment before' => ['/*comment*/body', 'body'],
190+
'comment after' => ['body/*comment*/', 'body'],
191+
'comment within' => ['./*comment*/teapot', '.teapot'],
192+
'comment within function' => [':not(#your-mug,/*comment*/.their-mug)', ':not(#your-mug,.their-mug)'],
193+
];
194+
}
195+
196+
/**
197+
* @test
198+
*
199+
* @param non-empty-string $selectorWith
200+
* @param non-empty-string $selectorWithout
201+
*
202+
* @dataProvider provideSelectorsWithAndWithoutComment
203+
*/
204+
public function parsesSelectorWithComment(string $selectorWith, string $selectorWithout): void
205+
{
206+
$result = Selector::parse(new ParserState($selectorWith, Settings::create()));
207+
208+
self::assertInstanceOf(Selector::class, $result);
209+
self::assertSame($selectorWithout, $result->getSelector());
210+
}
211+
212+
/**
213+
* @test
214+
*
215+
* @param non-empty-string $selector
216+
*
217+
* @dataProvider provideSelectorsWithAndWithoutComment
218+
*/
219+
public function parseExtractsCommentFromSelector(string $selector): void
220+
{
221+
$result = [];
222+
Selector::parse(new ParserState($selector, Settings::create()), $result);
223+
224+
self::assertArrayHasKey(0, $result);
225+
self::assertInstanceOf(Comment::class, $result[0]);
226+
self::assertSame('comment', $result[0]->getComment());
227+
}
228+
229+
/**
230+
* @test
231+
*/
232+
public function parsesSelectorWithTwoComments(): void
233+
{
234+
$result = Selector::parse(new ParserState('/*comment1*/a/*comment2*/', Settings::create()));
235+
236+
self::assertInstanceOf(Selector::class, $result);
237+
self::assertSame('a', $result->getSelector());
238+
}
239+
240+
/**
241+
* @test
242+
*/
243+
public function parseExtractsTwoCommentsFromSelector(): void
244+
{
245+
$result = [];
246+
Selector::parse(new ParserState('/*comment1*/a/*comment2*/', Settings::create()), $result);
247+
248+
self::assertArrayHasKey(0, $result);
249+
self::assertInstanceOf(Comment::class, $result[0]);
250+
self::assertSame('comment1', $result[0]->getComment());
251+
self::assertArrayHasKey(1, $result);
252+
self::assertInstanceOf(Comment::class, $result[1]);
253+
self::assertSame('comment2', $result[1]->getComment());
254+
}
255+
96256
/**
97257
* @test
98258
*

0 commit comments

Comments
 (0)