Skip to content

Commit dc18ba7

Browse files
committed
Fix parsing CSS selectors which contain commas <#1>, minus tests
1 parent e41d214 commit dc18ba7

File tree

2 files changed

+64
-1
lines changed

2 files changed

+64
-1
lines changed

src/Property/Selector.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class Selector
5656
[a-zA-Z0-9\x{00A0}-\x{FFFF}_^$|*="\'~\[\]()\-\s\.:#+>]* # any sequence of valid unescaped characters
5757
(?:\\\\.)? # a single escaped character
5858
(?:([\'"]).*?(?<!\\\\)\2)? # a quoted text like [id="example"]
59+
(?:\(.*?\))? # an argument for pseudo selector like :not(a,b)
5960
)*
6061
)$
6162
/ux';

src/RuleSet/DeclarationBlock.php

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,19 @@ public function setSelectors($mSelector, $oList = null)
9595
if (is_array($mSelector)) {
9696
$this->aSelectors = $mSelector;
9797
} else {
98-
$this->aSelectors = explode(',', $mSelector);
98+
list($sSelectors, $aPlaceholders) = $this->addSelectorExpressionPlaceholders($mSelector);
99+
if (empty($aPlaceholders)) {
100+
$this->aSelectors = explode(',', $sSelectors);
101+
} else {
102+
$aSearches = array_keys($aPlaceholders);
103+
$aReplaces = array_values($aPlaceholders);
104+
$this->aSelectors = array_map(
105+
static function ($sSelector) use ($aSearches, $aReplaces) {
106+
return str_replace($aSearches, $aReplaces, $sSelector);
107+
},
108+
explode(',', $sSelectors)
109+
);
110+
}
99111
}
100112
foreach ($this->aSelectors as $iKey => $mSelector) {
101113
if (!($mSelector instanceof Selector)) {
@@ -122,6 +134,56 @@ public function setSelectors($mSelector, $oList = null)
122134
}
123135
}
124136

137+
/**
138+
* Add placeholders for parenthetical expressions in selectors which may contain commas that break exploding.
139+
*
140+
* This prevents a single selector like `.widget:not(.foo, .bar)` from erroneously getting parsed in setSelectors as
141+
* two selectors `.widget:not(.foo` and `.bar)`.
142+
*
143+
* @param string $sSelectors Selectors.
144+
* @return array First array value is the selectors with placeholders, and second value is the array of placeholders
145+
* mapped to the original expressions.
146+
*/
147+
private function addSelectorExpressionPlaceholders($sSelectors)
148+
{
149+
$iOffset = 0;
150+
$aPlaceholders = [];
151+
152+
while (preg_match('/\(|\[/', $sSelectors, $aMatches, PREG_OFFSET_CAPTURE, $iOffset)) {
153+
$sMatchString = $aMatches[0][0];
154+
$iMatchOffset = $aMatches[0][1];
155+
$iStyleLength = strlen($sSelectors);
156+
$iOpenParens = 1;
157+
$iStartOffset = $iMatchOffset + strlen($sMatchString);
158+
$iFinalOffset = $iStartOffset;
159+
for (; $iFinalOffset < $iStyleLength; $iFinalOffset++) {
160+
if ('(' === $sSelectors[ $iFinalOffset ] || '[' === $sSelectors[ $iFinalOffset ]) {
161+
$iOpenParens++;
162+
} elseif (')' === $sSelectors[ $iFinalOffset ] || ']' === $sSelectors[ $iFinalOffset ]) {
163+
$iOpenParens--;
164+
}
165+
166+
// Found the end of the expression, so replace it with a placeholder.
167+
if (0 === $iOpenParens) {
168+
$sMatchedExpr = substr($sSelectors, $iMatchOffset, $iFinalOffset - $iMatchOffset + 1);
169+
$sPlaceholder = sprintf('{placeholder:%d}', count($aPlaceholders) + 1);
170+
$aPlaceholders[ $sPlaceholder ] = $sMatchedExpr;
171+
172+
// Update the CSS to replace the matched calc() with the placeholder function.
173+
$sSelectors = substr($sSelectors, 0, $iMatchOffset)
174+
. $sPlaceholder
175+
. substr($sSelectors, $iFinalOffset + 1);
176+
// Update offset based on difference of length of placeholder vs original matched calc().
177+
$iFinalOffset += strlen($sPlaceholder) - strlen($sMatchedExpr);
178+
break;
179+
}
180+
}
181+
// Start matching at the next byte after the match.
182+
$iOffset = $iFinalOffset + 1;
183+
}
184+
return [ $sSelectors, $aPlaceholders ];
185+
}
186+
125187
/**
126188
* Remove one of the selectors of the block.
127189
*

0 commit comments

Comments
 (0)