Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion src/Property/Selector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Comment> $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;
Comment thread
oliverklee marked this conversation as resolved.
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;
Expand Down
162 changes: 162 additions & 0 deletions tests/Unit/Property/SelectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<non-empty-string, array{0: string}>
*/
Expand All @@ -93,6 +113,148 @@ public static function provideInvalidSelectors(): array
];
}

/**
* @return array<non-empty-string, array{0: string}>
*/
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]'],
Comment thread
oliverklee marked this conversation as resolved.
'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<non-empty-string, array{0: non-empty-string}>
*/
public static function provideStopCharacters(): array
{
return [
',' => [','],
'{' => ['{'],
'}' => ['}'],
];
}

/**
* @return DataProvider<non-empty-string, array{0: non-empty-string, 1: non-empty-string}>
*/
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<non-empty-string, array{0: non-empty-string, 1: non-empty-string}>
*/
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)'],
Comment thread
oliverklee marked this conversation as resolved.
];
}

/**
* @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());
Comment thread
oliverklee marked this conversation as resolved.
}

/**
* @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
*
Expand Down