Skip to content

Commit ae80d04

Browse files
authored
[TASK] Add Selector::parse() (#1475)
The implementation is based on `DeclarationBlock::parseSelector()`, which will be updated to use this instead in #1470. Part of #1325.
1 parent 68670ab commit ae80d04

2 files changed

Lines changed: 266 additions & 1 deletion

File tree

src/Property/Selector.php

Lines changed: 104 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,111 @@ 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+
default:
137+
// This will never happen unless something gets broken in `ParserState`.
138+
throw new \UnexpectedValueException(
139+
'Unexpected character \'' . $nextCharacter
140+
. '\' returned from `ParserState::peek()` in `Selector::parse()`'
141+
);
142+
}
143+
$selectorParts[] = $parserState->consume(1);
144+
}
145+
146+
if ($functionNestingLevel !== 0) {
147+
throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
148+
}
149+
if (\is_string($stringWrapperCharacter)) {
150+
throw new UnexpectedTokenException(
151+
$stringWrapperCharacter,
152+
$nextCharacter,
153+
'literal',
154+
$parserState->currentLine()
155+
);
156+
}
157+
158+
$selector = \trim(\implode('', $selectorParts));
159+
if ($selector === '') {
160+
throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
161+
}
162+
if (!self::isValid($selector)) {
163+
throw new UnexpectedTokenException(
164+
"Selector did not match '" . static::SELECTOR_VALIDATION_RX . "'.",
165+
$selector,
166+
'custom',
167+
$parserState->currentLine()
168+
);
169+
}
170+
171+
return new static($selector);
172+
}
173+
71174
public function getSelector(): string
72175
{
73176
return $this->selector;

tests/Unit/Property/SelectorTest.php

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

0 commit comments

Comments
 (0)