Skip to content

Commit 08156dc

Browse files
committed
Implemented nth-child pseudoclass support
1 parent 4e01a43 commit 08156dc

File tree

6 files changed

+104
-32
lines changed

6 files changed

+104
-32
lines changed

composer.json

+8-3
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,20 @@
2424
"myclabs/php-enum": "^1.7"
2525
},
2626
"require-dev": {
27-
"phpunit/phpunit": "^7.5.1",
27+
"phpunit/phpunit": "^10.5",
2828
"mockery/mockery": "^1.2",
29-
"infection/infection": "^0.13.4",
30-
"phan/phan": "^2.4",
29+
"infection/infection": ">=0.13.4",
30+
"phan/phan": ">=2.4",
3131
"friendsofphp/php-cs-fixer": "^2.16"
3232
},
3333
"autoload": {
3434
"psr-4": {
3535
"PHPHtmlParser\\": "src/PHPHtmlParser"
3636
}
37+
},
38+
"config": {
39+
"allow-plugins": {
40+
"infection/extension-installer": true
41+
}
3742
}
3843
}

src/PHPHtmlParser/Dom/Node/InnerNode.php

+11
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ public function countChildren(): int
100100
return \count($this->children);
101101
}
102102

103+
public function childNodes(): array
104+
{
105+
return $this->children;
106+
}
107+
108+
public function childElements(): array
109+
{
110+
return array_values(array_filter($this->getChildren(), function ($el) {
111+
return !$el->isTextNode();
112+
}));
113+
}
103114
/**
104115
* Adds a child node to this node and returns the id of the child for this
105116
* parent.

src/PHPHtmlParser/Selector/Parser.php

+16-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class Parser implements ParserInterface
1919
*
2020
* @var string
2121
*/
22-
private $pattern = "/([\w\-:\*>]*)(?:\#([\w\-]+)|\.([\w\.\-]+))?(?:\[@?(!?[\w\-:]+)(?:([!*^$]?=)[\"']?(.*?)[\"']?)?\])?([\/, ]+)/is";
22+
private $pattern = "/([\w:*>+~-]*(?:\([\w\d]+\))?)(?:#([\w-]+)|\.([\w\.-]+))?(?:\[@?(!?[\w:-]+)(?:([!*^$]?=)[\"']?(.*?)[\"']?)?\])?([\/, ]+)/is";
2323

2424
/**
2525
* Parses the selector string.
@@ -58,6 +58,21 @@ public function parseSelectorString(string $selector): ParsedSelectorCollectionD
5858
$value = \explode('.', $match[3]);
5959
}
6060

61+
// check for pseudoclass selector
62+
if ($match[0][0] == ':') {
63+
$key = 'pseudoclass';
64+
$tag = '*';
65+
$value = \substr($match[0], 1);
66+
67+
if (preg_match("/^nth-child\(\d+\)$/", \trim($value, ', '))) {
68+
preg_match_all("/^nth-child\((\d+)\)$/", \trim($value, ', '), $matches, PREG_SET_ORDER);
69+
$key = (int) $matches[0][1];
70+
} else if (preg_match("/^nth-last-child\(\d+\)$/", \trim($value, ', '))) {
71+
preg_match_all("/^nth-last-child\((\d+)\)$/", \trim($value, ', '), $matches, PREG_SET_ORDER);
72+
$key = - (int) $matches[0][1];
73+
}
74+
}
75+
6176
// and final attribute selector
6277
if (!empty($match[4])) {
6378
$key = \strtolower($match[4]);

src/PHPHtmlParser/Selector/Seeker.php

+28-27
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use PHPHtmlParser\Contracts\Selector\SeekerInterface;
88
use PHPHtmlParser\Dom\Node\AbstractNode;
9+
use PHPHtmlParser\Dom\Node\HtmlNode;
910
use PHPHtmlParser\Dom\Node\InnerNode;
1011
use PHPHtmlParser\Dom\Node\LeafNode;
1112
use PHPHtmlParser\DTO\Selector\RuleDTO;
@@ -23,24 +24,6 @@ class Seeker implements SeekerInterface
2324
*/
2425
public function seek(array $nodes, RuleDTO $rule, array $options): array
2526
{
26-
// XPath index
27-
if ($rule->getTag() !== null && \is_numeric($rule->getKey())) {
28-
$count = 0;
29-
foreach ($nodes as $node) {
30-
if ($rule->getTag() == '*'
31-
|| $rule->getTag() == $node->getTag()
32-
->name()
33-
) {
34-
++$count;
35-
if ($count == $rule->getKey()) {
36-
// found the node we wanted
37-
return [$node];
38-
}
39-
}
40-
}
41-
42-
return [];
43-
}
4427

4528
$options = $this->flattenOptions($options);
4629

@@ -62,16 +45,34 @@ public function seek(array $nodes, RuleDTO $rule, array $options): array
6245
continue;
6346
}
6447

65-
$pass = $this->checkTag($rule, $child);
66-
if ($pass && $rule->getKey() !== null) {
67-
$pass = $this->checkKey($rule, $child);
48+
if (!$child instanceof HtmlNode) {
49+
$child = $this->getNextChild($node, $child);
50+
continue;
6851
}
69-
if ($pass &&
70-
$rule->getKey() !== null &&
71-
$rule->getValue() !== null &&
72-
$rule->getValue() != '*'
73-
) {
74-
$pass = $this->checkComparison($rule, $child);
52+
53+
$pass = true;
54+
55+
if ($rule->getTag() !== null && \is_numeric($rule->getKey()) && $node instanceof HtmlNode) {
56+
if (strpos($rule->getValue(), 'nth-') === 0) {
57+
$children = $node->childElements();
58+
$n = $rule->getKey() < 0 ? count($children) + $rule->getKey() : $rule->getKey()-1;
59+
$pass = $n >= 0 && $n < count($children) && $child == $children[$n];
60+
}
61+
}
62+
63+
if ($pass) {
64+
$pass = $this->checkTag($rule, $child);
65+
if ($pass && $rule->getKey() !== null && !\is_numeric($rule->getKey())) {
66+
$pass = $this->checkKey($rule, $child);
67+
}
68+
if ($pass &&
69+
$rule->getKey() !== null &&
70+
$rule->getValue() !== null &&
71+
$rule->getValue() != '*' &&
72+
!\is_numeric($rule->getKey())
73+
) {
74+
$pass = $this->checkComparison($rule, $child);
75+
}
7576
}
7677

7778
if ($pass) {

src/PHPHtmlParser/Selector/Selector.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public function find(AbstractNode $node): Collection
7171

7272
$options = [];
7373
foreach ($selector->getRules() as $rule) {
74-
if ($rule->isAlterNext()) {
74+
if ($rule->isAlterNext() && $rule->getTag() == '>') {
7575
$options[] = $this->alterNext($rule);
7676
continue;
7777
}

tests/Selector/SeekerTest.php

+40
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
declare(strict_types=1);
44

5+
use PHPHtmlParser\Dom\Node\HtmlNode;
56
use PHPHtmlParser\DTO\Selector\RuleDTO;
67
use PHPHtmlParser\Selector\Seeker;
78
use PHPUnit\Framework\TestCase;
@@ -22,4 +23,43 @@ public function testSeekReturnEmptyArray()
2223
$results = $seeker->seek([], $ruleDTO, []);
2324
$this->assertCount(0, $results);
2425
}
26+
27+
public function testSeekNthChild()
28+
{
29+
$ruleDTO = RuleDTO::makeFromPrimitives(
30+
'*',
31+
'=',
32+
1,
33+
null,
34+
false,
35+
false
36+
);
37+
38+
$test = new HtmlNode('div');
39+
$p1 = new HtmlNode('p');
40+
$div = new HtmlNode('div');
41+
$p2 = new HtmlNode('p');
42+
$test->addChild($p1);
43+
$test->addChild($div);
44+
$test->addChild($p2);
45+
46+
$seeker = new Seeker();
47+
48+
$results = $seeker->seek([$test], $ruleDTO, []);
49+
$this->assertCount(1, $results);
50+
$this->assertEquals('p', $results[0]->getTag()->name());
51+
52+
$ruleDTO = RuleDTO::makeFromPrimitives(
53+
'*',
54+
'=',
55+
-1,
56+
null,
57+
false,
58+
false
59+
);
60+
61+
$results = $seeker->seek([$test], $ruleDTO, []);
62+
$this->assertCount(1, $results);
63+
$this->assertEquals('p', $results[0]->getTag()->name());
64+
}
2565
}

0 commit comments

Comments
 (0)