diff --git a/lib/Sabberworm/CSS/CSSList/CSSList.php b/lib/Sabberworm/CSS/CSSList/CSSList.php index d883df82..5def89a0 100644 --- a/lib/Sabberworm/CSS/CSSList/CSSList.php +++ b/lib/Sabberworm/CSS/CSSList/CSSList.php @@ -83,16 +83,18 @@ private static function parseListItem(ParserState $oParserState, CSSList $oList) } return $oAtRule; } else if ($oParserState->comes('}')) { - $oParserState->consume('}'); - if ($bIsRoot) { - if ($oParserState->getSettings()->bLenientParsing) { - while ($oParserState->comes('}')) $oParserState->consume('}'); - return DeclarationBlock::parse($oParserState); + if (!$oParserState->getSettings()->bLenientParsing) { + throw new UnexpectedTokenException('CSS selector', '}', 'identifier', $oParserState->currentLine()); + } else { + if ($bIsRoot) { + if ($oParserState->getSettings()->bLenientParsing) { + return DeclarationBlock::parse($oParserState); + } else { + throw new SourceException("Unopened {", $oParserState->currentLine()); + } } else { - throw new SourceException("Unopened {", $oParserState->currentLine()); + return null; } - } else { - return null; } } else { return DeclarationBlock::parse($oParserState); @@ -123,6 +125,9 @@ private static function parseAtRule(ParserState $oParserState) { $oResult->setVendorKeyFrame($sIdentifier); $oResult->setAnimationName(trim($oParserState->consumeUntil('{', false, true))); CSSList::parseList($oParserState, $oResult); + if ($oParserState->comes('}')) { + $oParserState->consume('}'); + } return $oResult; } else if ($sIdentifier === 'namespace') { $sPrefix = null; @@ -162,6 +167,9 @@ private static function parseAtRule(ParserState $oParserState) { } else { $oAtRule = new AtRuleBlockList($sIdentifier, $sArgs, $iIdentifierLineNum); CSSList::parseList($oParserState, $oAtRule); + if ($oParserState->comes('}')) { + $oParserState->consume('}'); + } } return $oAtRule; } @@ -264,6 +272,9 @@ public function removeDeclarationBlockBySelector($mSelector, $bRemoveAll = false } foreach ($mSelector as $iKey => &$mSel) { if (!($mSel instanceof Selector)) { + if (!Selector::isValid($mSel)) { + throw new UnexpectedTokenException("Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.", $mSel, "custom"); + } $mSel = new Selector($mSel); } } diff --git a/lib/Sabberworm/CSS/Property/Selector.php b/lib/Sabberworm/CSS/Property/Selector.php index d84171f5..745a4b29 100644 --- a/lib/Sabberworm/CSS/Property/Selector.php +++ b/lib/Sabberworm/CSS/Property/Selector.php @@ -2,6 +2,8 @@ namespace Sabberworm\CSS\Property; +use Sabberworm\CSS\Parsing\UnexpectedTokenException; + /** * Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this class. */ @@ -35,9 +37,23 @@ class Selector { )) /ix'; + const SELECTOR_VALIDATION_RX = '/ + ^( + (?: + [a-zA-Z0-9\x{00A0}-\x{FFFF}_^$|*="\'~\[\]()\-\s\.:#+>]* # any sequence of valid unescaped characters + (?:\\\\.)? # a single escaped character + (?:([\'"]).*?(?setSelector($sSelector); if ($bCalculateSpecificity) { diff --git a/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php b/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php index 6614b1d1..08c3b0fd 100644 --- a/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php +++ b/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php @@ -4,6 +4,7 @@ use Sabberworm\CSS\Parsing\ParserState; use Sabberworm\CSS\Parsing\OutputException; +use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Property\Selector; use Sabberworm\CSS\Rule\Rule; use Sabberworm\CSS\Value\RuleValueList; @@ -28,7 +29,33 @@ public function __construct($iLineNo = 0) { public static function parse(ParserState $oParserState) { $aComments = array(); $oResult = new DeclarationBlock($oParserState->currentLine()); - $oResult->setSelector($oParserState->consumeUntil('{', false, true, $aComments)); + try { + $aSelectorParts = array(); + $sStringWrapperChar = false; + do { + $aSelectorParts[] = $oParserState->consume(1) . $oParserState->consumeUntil(array('{', '}', '\'', '"'), false, false, $aComments); + if ( in_array($oParserState->peek(), array('\'', '"')) && substr(end($aSelectorParts), -1) != "\\" ) { + if ( $sStringWrapperChar === false ) { + $sStringWrapperChar = $oParserState->peek(); + } else if ($sStringWrapperChar == $oParserState->peek()) { + $sStringWrapperChar = false; + } + } + } while (!in_array($oParserState->peek(), array('{', '}')) || $sStringWrapperChar !== false); + $oResult->setSelector(implode('', $aSelectorParts)); + if ($oParserState->comes('{')) { + $oParserState->consume(1); + } + } catch (UnexpectedTokenException $e) { + if($oParserState->getSettings()->bLenientParsing) { + if(!$oParserState->comes('}')) { + $oParserState->consumeUntil('}', false, true); + } + return false; + } else { + throw $e; + } + } $oResult->setComments($aComments); RuleSet::parseRuleSet($oParserState, $oResult); return $oResult; @@ -43,6 +70,9 @@ public function setSelectors($mSelector) { } foreach ($this->aSelectors as $iKey => $mSelector) { if (!($mSelector instanceof Selector)) { + if (!Selector::isValid($mSelector)) { + throw new UnexpectedTokenException("Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.", $mSelector, "custom"); + } $this->aSelectors[$iKey] = new Selector($mSelector); } } diff --git a/tests/Sabberworm/CSS/ParserTest.php b/tests/Sabberworm/CSS/ParserTest.php index d9f3ec64..d7e6ce69 100644 --- a/tests/Sabberworm/CSS/ParserTest.php +++ b/tests/Sabberworm/CSS/ParserTest.php @@ -432,6 +432,47 @@ function testUnmatchedBracesInFile() { $this->assertSame($sExpected, $oDoc->render()); } + function testInvalidSelectorsInFile() { + $oDoc = $this->parsedStructureForFile('invalid-selectors', Settings::create()->withMultibyteSupport(true)); + $sExpected = '@keyframes mymove {from {top: 0px;}} +#test {color: white;background: green;} +#test {display: block;background: white;color: black;}'; + $this->assertSame($sExpected, $oDoc->render()); + + $oDoc = $this->parsedStructureForFile('invalid-selectors-2', Settings::create()->withMultibyteSupport(true)); + $sExpected = '@media only screen and (max-width: 1215px) {.breadcrumb {padding-left: 10px;} + .super-menu > li:first-of-type {border-left-width: 0;} + .super-menu > li:last-of-type {border-right-width: 0;} + html[dir="rtl"] .super-menu > li:first-of-type {border-left-width: 1px;border-right-width: 0;} + html[dir="rtl"] .super-menu > li:last-of-type {border-left-width: 0;}} +body {background-color: red;}'; + $this->assertSame($sExpected, $oDoc->render()); + } + + function testSelectorEscapesInFile() { + $oDoc = $this->parsedStructureForFile('selector-escapes', Settings::create()->withMultibyteSupport(true)); + $sExpected = '#\# {color: red;} +.col-sm-1\/5 {width: 20%;}'; + $this->assertSame($sExpected, $oDoc->render()); + + $oDoc = $this->parsedStructureForFile('invalid-selectors-2', Settings::create()->withMultibyteSupport(true)); + $sExpected = '@media only screen and (max-width: 1215px) {.breadcrumb {padding-left: 10px;} + .super-menu > li:first-of-type {border-left-width: 0;} + .super-menu > li:last-of-type {border-right-width: 0;} + html[dir="rtl"] .super-menu > li:first-of-type {border-left-width: 1px;border-right-width: 0;} + html[dir="rtl"] .super-menu > li:last-of-type {border-left-width: 0;}} +body {background-color: red;}'; + $this->assertSame($sExpected, $oDoc->render()); + } + + function testSelectorIgnoresInFile() { + $oDoc = $this->parsedStructureForFile('selector-ignores', Settings::create()->withMultibyteSupport(true)); + $sExpected = '.some[selectors-may=\'contain-a-{\'] {} +.this-selector .valid {width: 100px;} +@media only screen and (min-width: 200px) {.test {prop: val;}}'; + $this->assertSame($sExpected, $oDoc->render()); + } + /** * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException */ @@ -501,7 +542,7 @@ function testCharsetFailure2() { * @expectedException \Sabberworm\CSS\Parsing\SourceException */ function testUnopenedClosingBracketFailure() { - $this->parsedStructureForFile('unopened-close-brackets', Settings::create()->withLenientParsing(false)); + $this->parsedStructureForFile('-unopened-close-brackets', Settings::create()->withLenientParsing(false)); } /** diff --git a/tests/files/unopened-close-brackets.css b/tests/files/-unopened-close-brackets.css similarity index 100% rename from tests/files/unopened-close-brackets.css rename to tests/files/-unopened-close-brackets.css diff --git a/tests/files/invalid-selectors-2.css b/tests/files/invalid-selectors-2.css new file mode 100644 index 00000000..3398c62e --- /dev/null +++ b/tests/files/invalid-selectors-2.css @@ -0,0 +1,33 @@ +@media only screen and (max-width: 1215px) { +.breadcrumb{ +padding-left:10px; +} +.super-menu > li:first-of-type{ +border-left-width:0; +} +.super-menu > li:last-of-type{ +border-right-width:0; +} +html[dir="rtl"] .super-menu > li:first-of-type{ +border-left-width:1px; +border-right-width:0; +} +html[dir="rtl"] .super-menu > li:last-of-type{ +border-left-width:0; +} +html[dir="rtl"] .super-menu.menu-floated > li:first-of-type +border-right-width:0; +} +} + + +.super-menu.menu-floated{ +border-right-width:1px; +border-left-width:1px; +border-color:rgb(90, 66, 66); +border-style:dotted; +} + +body { + background-color: red; +} diff --git a/tests/files/invalid-selectors.css b/tests/files/invalid-selectors.css new file mode 100644 index 00000000..1c633c57 --- /dev/null +++ b/tests/files/invalid-selectors.css @@ -0,0 +1,24 @@ +@keyframes mymove { + from { top: 0px; } +} + +#test { + color: white; + background: green; +} + +body + background: black; + } + +#test { + display: block; + background: red; + color: white; +} +#test { + display: block; + background: white; + color: black; +} + diff --git a/tests/files/selector-escapes.css b/tests/files/selector-escapes.css new file mode 100644 index 00000000..7797e06f --- /dev/null +++ b/tests/files/selector-escapes.css @@ -0,0 +1,7 @@ +#\# { + color: red; +} + +.col-sm-1\/5 { + width: 20%; +} diff --git a/tests/files/-tobedone.css b/tests/files/selector-ignores.css similarity index 62% rename from tests/files/-tobedone.css rename to tests/files/selector-ignores.css index d9fc1117..5834e009 100644 --- a/tests/files/-tobedone.css +++ b/tests/files/selector-ignores.css @@ -1,9 +1,13 @@ .some[selectors-may='contain-a-{'] { - + +} + +.this-selector /* should remain-} */ .valid { + width:100px; } @media only screen and (min-width: 200px) { .test { prop: val; } -} \ No newline at end of file +}