Skip to content

Commit a63d79e

Browse files
committed
[FEATURE] Add a utility class to calculate selector specificity
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 713eec9 commit a63d79e

File tree

2 files changed

+120
-0
lines changed

2 files changed

+120
-0
lines changed

src/Property/DependencyCalculator.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sabberworm\CSS\Property;
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+
* @internal
13+
*/
14+
final class DependencyCalculator
15+
{
16+
/**
17+
* regexp for specificity calculations
18+
*
19+
* @var non-empty-string
20+
*/
21+
private const NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX = '/
22+
(\\.[\\w]+) # classes
23+
|
24+
\\[(\\w+) # attributes
25+
|
26+
(\\:( # pseudo classes
27+
link|visited|active
28+
|hover|focus
29+
|lang
30+
|target
31+
|enabled|disabled|checked|indeterminate
32+
|root
33+
|nth-child|nth-last-child|nth-of-type|nth-last-of-type
34+
|first-child|last-child|first-of-type|last-of-type
35+
|only-child|only-of-type
36+
|empty|contains
37+
))
38+
/ix';
39+
40+
/**
41+
* regexp for specificity calculations
42+
*
43+
* @var non-empty-string
44+
*/
45+
private const ELEMENTS_AND_PSEUDO_ELEMENTS_RX = '/
46+
((^|[\\s\\+\\>\\~]+)[\\w]+ # elements
47+
|
48+
\\:{1,2}( # pseudo-elements
49+
after|before|first-letter|first-line|selection
50+
))
51+
/ix';
52+
53+
/**
54+
* @var array<string, int<0, max>>
55+
*/
56+
private static $specificityCache = [];
57+
58+
/**
59+
* @return int<0, max>
60+
*/
61+
public static function calculateSpecificity(string $selector): int
62+
{
63+
if (!isset(self::$specificityCache[$selector])) {
64+
$a = 0;
65+
/// @todo should exclude \# as well as "#"
66+
$aMatches = null;
67+
$b = \substr_count($selector, '#');
68+
$c = \preg_match_all(self::NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX, $selector, $aMatches);
69+
$d = \preg_match_all(self::ELEMENTS_AND_PSEUDO_ELEMENTS_RX, $selector, $aMatches);
70+
self::$specificityCache[$selector] = ($a * 1000) + ($b * 100) + ($c * 10) + $d;
71+
}
72+
73+
return self::$specificityCache[$selector];
74+
}
75+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sabberworm\CSS\Tests\Unit\Property;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Sabberworm\CSS\Property\DependencyCalculator;
9+
10+
/**
11+
* @covers \Sabberworm\CSS\Property\DependencyCalculator
12+
*/
13+
final class DependencyCalculatorTest extends TestCase
14+
{
15+
/**
16+
* @return array<string, array{0: non-empty-string, 1: int<0, max>}>
17+
*/
18+
public static function provideSelectorsAndSpecificities(): array
19+
{
20+
return [
21+
'element' => ['a', 1],
22+
'element and descendant with pseudo-selector' => ['ol li::before', 3],
23+
'class' => ['.highlighted', 10],
24+
'element with class' => ['li.green', 11],
25+
'class with pseudo-selector' => ['.help:hover', 20],
26+
'ID' => ['#file', 100],
27+
'ID and descendant class' => ['#test .help', 110],
28+
];
29+
}
30+
31+
/**
32+
* @test
33+
*
34+
* @param non-empty-string $selector
35+
* @param int<0, max> $expectedSpecificity
36+
*
37+
* @dataProvider provideSelectorsAndSpecificities
38+
*/
39+
public function calculateSpecificityReturnsSpecificityForProvidedSelector(
40+
string $selector,
41+
int $expectedSpecificity
42+
): void {
43+
self::assertSame($expectedSpecificity, DependencyCalculator::calculateSpecificity($selector));
44+
}
45+
}

0 commit comments

Comments
 (0)