Skip to content

Commit 2f99028

Browse files
authored
[TASK] Add CompoundSelector class (#1488)
Part of #1325.
1 parent 34df8f9 commit 2f99028

2 files changed

Lines changed: 750 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)