Skip to content

Commit 328481e

Browse files
committed
[TASK] Add CompoundSelector class
Part of #1325.
1 parent 104b94d commit 328481e

2 files changed

Lines changed: 749 additions & 0 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
use function Safe\preg_match;
15+
16+
/**
17+
* Class representing a CSS compound selector.
18+
* Selectors have to be split at combinators (space, `>`, `+`, `~`) before being passed to this class.
19+
*/
20+
class CompoundSelector implements Renderable, Component
21+
{
22+
use ShortClassNameProvider;
23+
24+
private const SELECTOR_VALIDATION_RX = '/
25+
^
26+
# not starting with whitespace
27+
(?!\\s)
28+
(?:
29+
(?:
30+
# any sequence of valid unescaped characters, except quotes
31+
[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=\\[\\]()\\-\\.:#,\\s]++
32+
|
33+
# one or more escaped characters
34+
(?:\\\\.)++
35+
|
36+
# quoted text, like in `[id="example"]`
37+
(?:
38+
# opening quote
39+
([\'"])
40+
(?:
41+
# sequence of characters except closing quote or backslash
42+
(?:(?!\\g{-1}|\\\\).)++
43+
|
44+
# one or more escaped characters
45+
(?:\\\\.)++
46+
)*+ # zero or more times
47+
# closing quote or end (unmatched quote is currently allowed)
48+
(?:\\g{-1}|$)
49+
)
50+
)++ # one or more times
51+
|
52+
# keyframe animation progress percentage (e.g. 50%)
53+
(?:\\d++%)
54+
)
55+
# not ending with whitespace
56+
(?<!\\s)
57+
$
58+
/ux';
59+
60+
/**
61+
* @var non-empty-string
62+
*/
63+
private $value;
64+
65+
/**
66+
* @param non-empty-string $value
67+
*/
68+
public function __construct(string $value)
69+
{
70+
$this->setValue($value);
71+
}
72+
73+
/**
74+
* @param list<Comment> $comments
75+
*
76+
* @throws UnexpectedTokenException
77+
*
78+
* @internal
79+
*/
80+
public static function parse(ParserState $parserState, array &$comments = []): self
81+
{
82+
$selectorParts = [];
83+
$stringWrapperCharacter = null;
84+
$functionNestingLevel = 0;
85+
static $stopCharacters = [
86+
'{',
87+
'}',
88+
'\'',
89+
'"',
90+
'(',
91+
')',
92+
',',
93+
' ',
94+
"\t",
95+
"\n",
96+
"\r",
97+
'>',
98+
'+',
99+
'~',
100+
ParserState::EOF,
101+
'',
102+
];
103+
104+
while (true) {
105+
$selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
106+
$nextCharacter = $parserState->peek();
107+
switch ($nextCharacter) {
108+
case '':
109+
// EOF
110+
break 2;
111+
case '\'':
112+
// The fallthrough is intentional.
113+
case '"':
114+
$lastPart = \end($selectorParts);
115+
$backslashCount = \strspn(\strrev($lastPart), '\\');
116+
$quoteIsEscaped = ($backslashCount % 2 === 1);
117+
if (!$quoteIsEscaped) {
118+
if (!\is_string($stringWrapperCharacter)) {
119+
$stringWrapperCharacter = $nextCharacter;
120+
} elseif ($stringWrapperCharacter === $nextCharacter) {
121+
$stringWrapperCharacter = null;
122+
}
123+
}
124+
break;
125+
case '(':
126+
if (!\is_string($stringWrapperCharacter)) {
127+
++$functionNestingLevel;
128+
}
129+
break;
130+
case ')':
131+
if (!\is_string($stringWrapperCharacter)) {
132+
if ($functionNestingLevel <= 0) {
133+
throw new UnexpectedTokenException(
134+
'anything but',
135+
')',
136+
'literal',
137+
$parserState->currentLine()
138+
);
139+
}
140+
--$functionNestingLevel;
141+
}
142+
break;
143+
case '{':
144+
// The fallthrough is intentional.
145+
case '}':
146+
if (!\is_string($stringWrapperCharacter)) {
147+
break 2;
148+
}
149+
break;
150+
case ',':
151+
// The fallthrough is intentional.
152+
case ' ':
153+
// The fallthrough is intentional.
154+
case "\t":
155+
// The fallthrough is intentional.
156+
case "\n":
157+
// The fallthrough is intentional.
158+
case "\r":
159+
// The fallthrough is intentional.
160+
case '>':
161+
// The fallthrough is intentional.
162+
case '+':
163+
// The fallthrough is intentional.
164+
case '~':
165+
if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) {
166+
break 2;
167+
}
168+
break;
169+
}
170+
$selectorParts[] = $parserState->consume(1);
171+
}
172+
173+
if ($functionNestingLevel !== 0) {
174+
throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
175+
}
176+
if (\is_string($stringWrapperCharacter)) {
177+
throw new UnexpectedTokenException(
178+
$stringWrapperCharacter,
179+
$nextCharacter,
180+
'literal',
181+
$parserState->currentLine()
182+
);
183+
}
184+
185+
$value = \implode('', $selectorParts);
186+
if ($value === '') {
187+
throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
188+
}
189+
if (!self::isValid($value)) {
190+
throw new UnexpectedTokenException(
191+
'Selector component is not valid:',
192+
'`' . $value . '`',
193+
'custom',
194+
$parserState->currentLine()
195+
);
196+
}
197+
198+
return new self($value);
199+
}
200+
201+
/**
202+
* @return non-empty-string
203+
*/
204+
public function getValue(): string
205+
{
206+
return $this->value;
207+
}
208+
209+
/**
210+
* @param non-empty-string $value
211+
*
212+
* @throws \UnexpectedValueException if `$value` contains invalid characters or has surrounding whitespce
213+
*/
214+
public function setValue(string $value): void
215+
{
216+
if (!self::isValid($value)) {
217+
throw new \UnexpectedValueException('`' . $value . '` is not a valid compound selector.');
218+
}
219+
220+
$this->value = $value;
221+
}
222+
223+
/**
224+
* @return int<0, max>
225+
*/
226+
public function getSpecificity(): int
227+
{
228+
return SpecificityCalculator::calculate($this->value);
229+
}
230+
231+
public function render(OutputFormat $outputFormat): string
232+
{
233+
return $this->getValue();
234+
}
235+
236+
/**
237+
* @return array<string, bool|int|float|string|array<mixed>|null>
238+
*
239+
* @internal
240+
*/
241+
public function getArrayRepresentation(): array
242+
{
243+
return [
244+
'class' => $this->getShortClassName(),
245+
'value' => $this->value,
246+
];
247+
}
248+
249+
private static function isValid(string $value): bool
250+
{
251+
$numberOfMatches = preg_match(self::SELECTOR_VALIDATION_RX, $value);
252+
253+
return $numberOfMatches === 1;
254+
}
255+
}

0 commit comments

Comments
 (0)