diff --git a/src/Property/Selector.php b/src/Property/Selector.php index b9cf9bbc..d7455453 100644 --- a/src/Property/Selector.php +++ b/src/Property/Selector.php @@ -4,7 +4,10 @@ namespace Sabberworm\CSS\Property; +use Sabberworm\CSS\Comment\Comment; use Sabberworm\CSS\OutputFormat; +use Sabberworm\CSS\Parsing\ParserState; +use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Property\Selector\SpecificityCalculator; use Sabberworm\CSS\Renderable; @@ -63,11 +66,111 @@ public static function isValid(string $selector): bool return $numberOfMatches === 1; } - public function __construct(string $selector) + final public function __construct(string $selector) { $this->setSelector($selector); } + /** + * @param list $comments + * + * @throws UnexpectedTokenException + * + * @internal + */ + public static function parse(ParserState $parserState, array &$comments = []): self + { + $selectorParts = []; + $stringWrapperCharacter = null; + $functionNestingLevel = 0; + static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',', ParserState::EOF, '']; + + while (true) { + $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments); + $nextCharacter = $parserState->peek(); + switch ($nextCharacter) { + case '': + // EOF + break 2; + case '\'': + // The fallthrough is intentional. + case '"': + if (!\is_string($stringWrapperCharacter)) { + $stringWrapperCharacter = $nextCharacter; + } elseif ($stringWrapperCharacter === $nextCharacter) { + if (\substr(\end($selectorParts), -1) !== '\\') { + $stringWrapperCharacter = null; + } + } + break; + case '(': + if (!\is_string($stringWrapperCharacter)) { + ++$functionNestingLevel; + } + break; + case ')': + if (!\is_string($stringWrapperCharacter)) { + if ($functionNestingLevel <= 0) { + throw new UnexpectedTokenException( + 'anything but', + ')', + 'literal', + $parserState->currentLine() + ); + } + --$functionNestingLevel; + } + break; + case ',': + if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) { + break 2; + } + break; + case '{': + // The fallthrough is intentional. + case '}': + if (!\is_string($stringWrapperCharacter)) { + break 2; + } + break; + default: + // This will never happen unless something gets broken in `ParserState`. + throw new \UnexpectedValueException( + 'Unexpected character \'' . $nextCharacter + . '\' returned from `ParserState::peek()` in `Selector::parse()`' + ); + } + $selectorParts[] = $parserState->consume(1); + } + + if ($functionNestingLevel !== 0) { + throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine()); + } + if (\is_string($stringWrapperCharacter)) { + throw new UnexpectedTokenException( + $stringWrapperCharacter, + $nextCharacter, + 'literal', + $parserState->currentLine() + ); + } + + $selector = \trim(\implode('', $selectorParts)); + if ($selector === '') { + throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine()); + } + if (!self::isValid($selector)) { + throw new UnexpectedTokenException( + "Selector did not match '" . static::SELECTOR_VALIDATION_RX . "'.", + $selector, + 'custom', + $parserState->currentLine() + ); + } + + return new static($selector); + } + public function getSelector(): string { return $this->selector; diff --git a/tests/Unit/Property/SelectorTest.php b/tests/Unit/Property/SelectorTest.php index 2b67dce4..4427e59e 100644 --- a/tests/Unit/Property/SelectorTest.php +++ b/tests/Unit/Property/SelectorTest.php @@ -5,8 +5,13 @@ namespace Sabberworm\CSS\Tests\Unit\Property; use PHPUnit\Framework\TestCase; +use Sabberworm\CSS\Comment\Comment; +use Sabberworm\CSS\Parsing\ParserState; +use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Property\Selector; use Sabberworm\CSS\Renderable; +use Sabberworm\CSS\Settings; +use TRegx\PhpUnit\DataProviders\DataProvider; /** * @covers \Sabberworm\CSS\Property\Selector @@ -73,6 +78,21 @@ public static function provideSelectorsAndSpecificities(): array ]; } + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideSelectorsAndSpecificities + */ + public function parsesValidSelector(string $selector): void + { + $result = Selector::parse(new ParserState($selector, Settings::create())); + + self::assertInstanceOf(Selector::class, $result); + self::assertSame($result->getSelector(), $selector); + } + /** * @return array */ @@ -93,6 +113,148 @@ public static function provideInvalidSelectors(): array ]; } + /** + * @return array + */ + public static function provideInvalidSelectorsForParse(): array + { + return [ + 'empty string' => [''], + 'space' => [' '], + 'tab' => ["\t"], + 'line feed' => ["\n"], + 'carriage return' => ["\r"], + 'a `:not` missing the closing brace' => [':not(a'], + 'a `:not` missing the opening brace' => [':not a)'], + 'attribute value missing closing single quote' => ['a[href=\'#top]'], + 'attribute value missing closing double quote' => ['a[href="#top]'], + 'attribute value with mismatched quotes, single quote opening' => ['a[href=\'#top"]'], + 'attribute value with mismatched quotes, double quote opening' => ['a[href="#top\']'], + ]; + } + + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideInvalidSelectors + * @dataProvider provideInvalidSelectorsForParse + */ + public function parseThrowsExceptionWithInvalidSelector(string $selector): void + { + $this->expectException(UnexpectedTokenException::class); + + Selector::parse(new ParserState($selector, Settings::create())); + } + + /** + * @return array + */ + public static function provideStopCharacters(): array + { + return [ + ',' => [','], + '{' => ['{'], + '}' => ['}'], + ]; + } + + /** + * @return DataProvider + */ + public function provideStopCharactersAndValidSelectors(): DataProvider + { + return DataProvider::cross(self::provideStopCharacters(), self::provideSelectorsAndSpecificities()); + } + + /** + * @test + * + * @param non-empty-string $stopCharacter + * @param non-empty-string $selector + * + * @dataProvider provideStopCharactersAndValidSelectors + */ + public function parseDoesNotConsumeStopCharacter(string $stopCharacter, string $selector): void + { + $subject = new ParserState($selector . $stopCharacter, Settings::create()); + + Selector::parse($subject); + + self::assertSame($stopCharacter, $subject->peek()); + } + + /** + * @return array + */ + public static function provideSelectorsWithAndWithoutComment(): array + { + return [ + 'comment before' => ['/*comment*/body', 'body'], + 'comment after' => ['body/*comment*/', 'body'], + 'comment within' => ['./*comment*/teapot', '.teapot'], + 'comment within function' => [':not(#your-mug,/*comment*/.their-mug)', ':not(#your-mug,.their-mug)'], + ]; + } + + /** + * @test + * + * @param non-empty-string $selectorWith + * @param non-empty-string $selectorWithout + * + * @dataProvider provideSelectorsWithAndWithoutComment + */ + public function parsesSelectorWithComment(string $selectorWith, string $selectorWithout): void + { + $result = Selector::parse(new ParserState($selectorWith, Settings::create())); + + self::assertInstanceOf(Selector::class, $result); + self::assertSame($selectorWithout, $result->getSelector()); + } + + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideSelectorsWithAndWithoutComment + */ + public function parseExtractsCommentFromSelector(string $selector): void + { + $result = []; + Selector::parse(new ParserState($selector, Settings::create()), $result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame('comment', $result[0]->getComment()); + } + + /** + * @test + */ + public function parsesSelectorWithTwoComments(): void + { + $result = Selector::parse(new ParserState('/*comment1*/a/*comment2*/', Settings::create())); + + self::assertInstanceOf(Selector::class, $result); + self::assertSame('a', $result->getSelector()); + } + + /** + * @test + */ + public function parseExtractsTwoCommentsFromSelector(): void + { + $result = []; + Selector::parse(new ParserState('/*comment1*/a/*comment2*/', Settings::create()), $result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame('comment1', $result[0]->getComment()); + self::assertInstanceOf(Comment::class, $result[1]); + self::assertSame('comment2', $result[1]->getComment()); + } + /** * @test *