Skip to content

Commit a037425

Browse files
authored
[FEATURE] Add a utility class to calculate selector specificity (#1049)
Calculating and caching the specificity of a selector is a different concern than representing a selector, and it deserves to be in its own class. This also helps solve the problem of selectors having keep their specificity cached and in sync with the selector itself. (We'll have a later change that changes `Selector` to use the new class.)
1 parent f912e71 commit a037425

File tree

2 files changed

+179
-0
lines changed

2 files changed

+179
-0
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sabberworm\CSS\Property\Selector;
6+
7+
/**
8+
* Utility class to calculate the specificity of a CSS selector.
9+
*
10+
* The results are cached to avoid recalculating the specificity of the same selector multiple times.
11+
*/
12+
final class SpecificityCalculator
13+
{
14+
/**
15+
* regexp for specificity calculations
16+
*
17+
* @var non-empty-string
18+
*/
19+
private const NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX = '/
20+
(\\.[\\w]+) # classes
21+
|
22+
\\[(\\w+) # attributes
23+
|
24+
(\\:( # pseudo classes
25+
link|visited|active
26+
|hover|focus
27+
|lang
28+
|target
29+
|enabled|disabled|checked|indeterminate
30+
|root
31+
|nth-child|nth-last-child|nth-of-type|nth-last-of-type
32+
|first-child|last-child|first-of-type|last-of-type
33+
|only-child|only-of-type
34+
|empty|contains
35+
))
36+
/ix';
37+
38+
/**
39+
* regexp for specificity calculations
40+
*
41+
* @var non-empty-string
42+
*/
43+
private const ELEMENTS_AND_PSEUDO_ELEMENTS_RX = '/
44+
((^|[\\s\\+\\>\\~]+)[\\w]+ # elements
45+
|
46+
\\:{1,2}( # pseudo-elements
47+
after|before|first-letter|first-line|selection
48+
))
49+
/ix';
50+
51+
/**
52+
* @var array<string, int<0, max>>
53+
*/
54+
private static $cache = [];
55+
56+
/**
57+
* Calculates the specificity of the given CSS selector.
58+
*
59+
* @return int<0, max>
60+
*
61+
* @internal
62+
*/
63+
public static function calculate(string $selector): int
64+
{
65+
if (!isset(self::$cache[$selector])) {
66+
$a = 0;
67+
/// @todo should exclude \# as well as "#"
68+
$aMatches = null;
69+
$b = \substr_count($selector, '#');
70+
$c = \preg_match_all(self::NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX, $selector, $aMatches);
71+
$d = \preg_match_all(self::ELEMENTS_AND_PSEUDO_ELEMENTS_RX, $selector, $aMatches);
72+
self::$cache[$selector] = ($a * 1000) + ($b * 100) + ($c * 10) + $d;
73+
}
74+
75+
return self::$cache[$selector];
76+
}
77+
78+
/**
79+
* Clears the cache in order to lower memory usage.
80+
*/
81+
public static function clearCache(): void
82+
{
83+
self::$cache = [];
84+
}
85+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sabberworm\CSS\Tests\Unit\Property\Selector;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Sabberworm\CSS\Property\Selector\SpecificityCalculator;
9+
10+
/**
11+
* @covers \Sabberworm\CSS\Property\Selector\SpecificityCalculator
12+
*/
13+
final class SpecificityCalculatorTest extends TestCase
14+
{
15+
protected function tearDown(): void
16+
{
17+
SpecificityCalculator::clearCache();
18+
}
19+
20+
/**
21+
* @return array<string, array{0: non-empty-string, 1: int<0, max>}>
22+
*/
23+
public static function provideSelectorsAndSpecificities(): array
24+
{
25+
return [
26+
'element' => ['a', 1],
27+
'element and descendant with pseudo-selector' => ['ol li::before', 3],
28+
'class' => ['.highlighted', 10],
29+
'element with class' => ['li.green', 11],
30+
'class with pseudo-selector' => ['.help:hover', 20],
31+
'ID' => ['#file', 100],
32+
'ID and descendant class' => ['#test .help', 110],
33+
];
34+
}
35+
36+
/**
37+
* @test
38+
*
39+
* @param non-empty-string $selector
40+
* @param int<0, max> $expectedSpecificity
41+
*
42+
* @dataProvider provideSelectorsAndSpecificities
43+
*/
44+
public function calculateReturnsSpecificityForProvidedSelector(
45+
string $selector,
46+
int $expectedSpecificity
47+
): void {
48+
self::assertSame($expectedSpecificity, SpecificityCalculator::calculate($selector));
49+
}
50+
51+
/**
52+
* @test
53+
*
54+
* @param non-empty-string $selector
55+
* @param int<0, max> $expectedSpecificity
56+
*
57+
* @dataProvider provideSelectorsAndSpecificities
58+
*/
59+
public function calculateAfterClearingCacheReturnsSpecificityForProvidedSelector(
60+
string $selector,
61+
int $expectedSpecificity
62+
): void {
63+
SpecificityCalculator::clearCache();
64+
65+
self::assertSame($expectedSpecificity, SpecificityCalculator::calculate($selector));
66+
}
67+
68+
/**
69+
* @test
70+
*/
71+
public function calculateCalledTwoTimesReturnsSameSpecificityForProvidedSelector(): void
72+
{
73+
$selector = '#test .help';
74+
75+
$firstResult = SpecificityCalculator::calculate($selector);
76+
$secondResult = SpecificityCalculator::calculate($selector);
77+
78+
self::assertSame($firstResult, $secondResult);
79+
}
80+
81+
/**
82+
* @test
83+
*/
84+
public function calculateCalledReturnsSameSpecificityForProvidedSelectorBeforeAndAfterClearingCache(): void
85+
{
86+
$selector = '#test .help';
87+
88+
$firstResult = SpecificityCalculator::calculate($selector);
89+
SpecificityCalculator::clearCache();
90+
$secondResult = SpecificityCalculator::calculate($selector);
91+
92+
self::assertSame($firstResult, $secondResult);
93+
}
94+
}

0 commit comments

Comments
 (0)