Skip to content

Commit 4cb01a1

Browse files
committed
feature: :not pseudo
1 parent bf6aba5 commit 4cb01a1

File tree

8 files changed

+201
-33
lines changed

8 files changed

+201
-33
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
/.idea
33
/test/unit/_coverage
44
/composer.phar
5-
.phpunit.*
5+
.phpunit.*
6+
# Local wiki symlink
7+
docs

phpcs.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
<rule ref="Generic.Files.EndFileNewline" />
2020
<rule ref="Generic.Files.InlineHTML" />
2121
<rule ref="Generic.Files.LineEndings" />
22-
<rule ref="Generic.Files.LineLength" />
2322
<rule ref="Generic.Files.OneClassPerFile" />
2423
<rule ref="Generic.Files.OneInterfacePerFile" />
2524
<rule ref="Generic.Files.OneObjectStructurePerFile" />

src/AttributeSelectorConverter.php

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,28 @@ public function apply(
2727

2828
$valueString = trim((string)$detailValue["content"], " '\"");
2929
$equalsType = $detailType["content"];
30-
$expression->appendFragment(
31-
$this->buildExpression($attribute, $valueString, $equalsType)
32-
);
30+
$condition = $this->buildCondition($attribute, $valueString, $equalsType);
31+
$expression->appendFragment("[{$condition}]");
32+
}
33+
34+
/** @param array<string, mixed> $token */
35+
public function buildConditionFromToken(array $token, bool $htmlMode):string {
36+
$attribute = (string)$token["content"];
37+
if($htmlMode) {
38+
$attribute = strtolower($attribute);
39+
}
40+
41+
$detail = $token["detail"] ?? null;
42+
$detailType = $detail[0] ?? null;
43+
$detailValue = $detail[1] ?? null;
44+
45+
if(!$this->hasEqualsType($detailType)) {
46+
return "@{$attribute}";
47+
}
48+
49+
$valueString = trim((string)$detailValue["content"], " '\"");
50+
$equalsType = $detailType["content"];
51+
return $this->buildCondition($attribute, $valueString, $equalsType);
3352
}
3453

3554
/** @param array<string, mixed>|null $detailType */
@@ -38,32 +57,32 @@ private function hasEqualsType(?array $detailType):bool {
3857
&& $detailType["type"] === "attribute_equals";
3958
}
4059

41-
private function buildExpression(
60+
private function buildCondition(
4261
string $attribute,
4362
string $value,
4463
string $equalsType
4564
):string {
4665
return match($equalsType) {
47-
Translator::EQUALS_EXACT => "[@{$attribute}=\"{$value}\"]",
48-
Translator::EQUALS_CONTAINS => "[contains(@{$attribute},\"{$value}\")]",
49-
Translator::EQUALS_CONTAINS_WORD => "["
66+
Translator::EQUALS_EXACT => "@{$attribute}=\"{$value}\"",
67+
Translator::EQUALS_CONTAINS => "contains(@{$attribute},\"{$value}\")",
68+
Translator::EQUALS_CONTAINS_WORD => ""
5069
. "contains(concat(\" \",@{$attribute},\" \"),"
5170
. "concat(\" \",\"{$value}\",\" \"))"
52-
. "]",
53-
Translator::EQUALS_OR_STARTS_WITH_HYPHENATED => "["
71+
. "",
72+
Translator::EQUALS_OR_STARTS_WITH_HYPHENATED => ""
5473
. "@{$attribute}=\"{$value}\" or "
5574
. "starts-with(@{$attribute}, \"{$value}-\")"
56-
. "]",
57-
Translator::EQUALS_STARTS_WITH => "["
75+
. "",
76+
Translator::EQUALS_STARTS_WITH => ""
5877
. "starts-with(@{$attribute}, \"{$value}\")"
59-
. "]",
60-
Translator::EQUALS_ENDS_WITH => "["
78+
. "",
79+
Translator::EQUALS_ENDS_WITH => ""
6180
. "substring(@{$attribute},"
6281
. "string-length(@{$attribute}) - "
6382
. "string-length(\"{$value}\") + 1)"
6483
. "=\"{$value}\""
65-
. "]",
66-
default => "[@{$attribute}]",
84+
. "",
85+
default => "@{$attribute}",
6786
};
6887
}
6988
}

src/PseudoSelectorConverter.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
class PseudoSelectorConverter {
66
/** @var array<int, string> */
77
private const BOOLEAN_ATTRIBUTES = ["disabled", "checked", "selected"];
8+
private NotSelectorConditionBuilder $notSelectorConditionBuilder;
9+
10+
public function __construct(
11+
?NotSelectorConditionBuilder $notSelectorConditionBuilder = null,
12+
) {
13+
$this->notSelectorConditionBuilder = $notSelectorConditionBuilder
14+
?? new NotSelectorConditionBuilder();
15+
}
816

917
/**
1018
* @param array<string, mixed> $token
@@ -13,7 +21,8 @@ class PseudoSelectorConverter {
1321
public function apply(
1422
array $token,
1523
?array $next,
16-
XPathExpression $expression
24+
XPathExpression $expression,
25+
bool $htmlMode
1726
):void {
1827
$pseudo = $token["content"];
1928
$specifier = $this->extractSpecifier($next);
@@ -26,6 +35,7 @@ public function apply(
2635
$handlers = [
2736
"text" => fn() => $this->applyText($expression),
2837
"contains" => fn() => $this->applyContains($expression, $specifier),
38+
"not" => fn() => $this->applyNot($expression, $specifier, $htmlMode),
2939
"first-child" => fn() => $expression->prependToLast("*[1]/self::"),
3040
"nth-child" => fn() => $this->applyNthChild($expression, $specifier),
3141
"last-child" => fn() => $expression->prependToLast("*[last()]/self::"),
@@ -83,6 +93,21 @@ private function applyNthOfType(
8393
$expression->appendFragment("[{$specifier}]");
8494
}
8595

96+
private function applyNot(
97+
XPathExpression $expression,
98+
string $specifier,
99+
bool $htmlMode
100+
):void {
101+
$combinedCondition = $this->notSelectorConditionBuilder
102+
->build($specifier, $htmlMode);
103+
if($combinedCondition === null) {
104+
return;
105+
}
106+
107+
$expression->ensureElement();
108+
$expression->appendFragment("[not({$combinedCondition})]");
109+
}
110+
86111
/** @param array<string, mixed>|null $next */
87112
private function extractSpecifier(?array $next):string {
88113
if(!$next || $next["type"] !== "pseudospecifier") {

src/SingleSelectorConverter.php

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@
33
namespace Gt\CssXPath;
44

55
class SingleSelectorConverter {
6+
private ThreadMatcher $threadMatcher;
7+
private PseudoSelectorConverter $pseudoSelectorConverter;
8+
private AttributeSelectorConverter $attributeSelectorConverter;
9+
610
public function __construct(
7-
private readonly ThreadMatcher $threadMatcher = new ThreadMatcher(),
8-
private readonly PseudoSelectorConverter $pseudoSelectorConverter
9-
= new PseudoSelectorConverter(),
10-
private readonly AttributeSelectorConverter $attributeSelectorConverter
11-
= new AttributeSelectorConverter(),
11+
?ThreadMatcher $threadMatcher = null,
12+
?PseudoSelectorConverter $pseudoSelectorConverter = null,
13+
?AttributeSelectorConverter $attributeSelectorConverter = null,
1214
) {
15+
$this->threadMatcher = $threadMatcher ?? new ThreadMatcher();
16+
$this->pseudoSelectorConverter = $pseudoSelectorConverter
17+
?? new PseudoSelectorConverter();
18+
$this->attributeSelectorConverter = $attributeSelectorConverter
19+
?? new AttributeSelectorConverter();
1320
}
1421

1522
public function convert(
@@ -18,7 +25,9 @@ public function convert(
1825
bool $htmlMode
1926
):string {
2027
$thread = array_values(
21-
$this->threadMatcher->collate(Translator::CSS_REGEX, $css)
28+
array_filter(
29+
$this->threadMatcher->collate(Translator::CSS_REGEX, $css)
30+
)
2231
);
2332
$expression = new XPathExpression($prefix);
2433

@@ -46,7 +55,7 @@ private function applyToken(
4655
"element" => fn() => $expression
4756
->appendElement((string)$token["content"], $htmlMode),
4857
"pseudo" => fn() => $this->pseudoSelectorConverter
49-
->apply($token, $next, $expression),
58+
->apply($token, $next, $expression, $htmlMode),
5059
"child" => fn() => $this->appendAxis($expression, "/"),
5160
"id" => fn() => $this->appendId($expression, (string)$token["content"]),
5261
"class" => fn() => $this

src/Translator.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class Translator {
77
'/'
88
. '(?P<star>\*)'
99
. '|(:(?P<pseudo>[\w-]*))'
10-
. '|\(*(?P<pseudospecifier>["\']*[\w\h-]*["\']*)\)'
10+
. '|\((?P<pseudospecifier>[^)]*)\)'
1111
. '|(?P<element>[\w-]*)'
1212
. '|(?P<child>\s*>\s*)'
1313
. '|(#(?P<id>[\w-]*))'
@@ -27,15 +27,19 @@ class Translator {
2727
public const EQUALS_STARTS_WITH = "^=";
2828

2929
private SingleSelectorConverter $singleSelectorConverter;
30+
private SelectorListSplitter $selectorListSplitter;
3031

3132
public function __construct(
3233
protected string $cssSelector,
3334
protected string $prefix = ".//",
3435
protected bool $htmlMode = true,
3536
?SingleSelectorConverter $singleSelectorConverter = null,
37+
?SelectorListSplitter $selectorListSplitter = null,
3638
) {
3739
$this->singleSelectorConverter = $singleSelectorConverter
3840
?? new SingleSelectorConverter();
41+
$this->selectorListSplitter = $selectorListSplitter
42+
?? new SelectorListSplitter();
3943
}
4044

4145
public function __toString():string {
@@ -49,10 +53,7 @@ public function asXPath():string {
4953
// phpcs:enable
5054

5155
protected function convert(string $css):string {
52-
$cssArray = preg_split(
53-
'/("|\').*?\1(*SKIP)(*F)|,/',
54-
$css
55-
);
56+
$cssArray = $this->selectorListSplitter->split($css);
5657
$xPathArray = [];
5758

5859
foreach($cssArray as $input) {

test/phpunit/ComponentConverterTest.php

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,16 @@ public function testPseudoSelectorConverterContainsRequiresSpecifier():void {
5252
$converter->apply(
5353
["type" => "pseudo", "content" => "contains"],
5454
null,
55-
$expression
55+
$expression,
56+
true
5657
);
5758
self::assertSame(".//p", $expression->toString());
5859

5960
$converter->apply(
6061
["type" => "pseudo", "content" => "contains"],
6162
["type" => "pseudospecifier", "content" => "'Example'"],
62-
$expression
63+
$expression,
64+
true
6365
);
6466
self::assertSame(".//p[contains(text(),'Example')]", $expression->toString());
6567
}
@@ -73,7 +75,8 @@ public function testPseudoSelectorConverterNthChildCanRefineExistingPredicate():
7375
$converter->apply(
7476
["type" => "pseudo", "content" => "nth-child"],
7577
["type" => "pseudospecifier", "content" => "2"],
76-
$expression
78+
$expression,
79+
true
7780
);
7881

7982
self::assertSame(
@@ -125,6 +128,57 @@ public function testAttributeSelectorConverterBuildsHyphenatedPrefixExpression()
125128
);
126129
}
127130

131+
public function testPseudoSelectorConverterNotBuildsElementAndClassCondition():void {
132+
$converter = new PseudoSelectorConverter();
133+
$expression = new XPathExpression(".//");
134+
$expression->appendElement("li", true);
135+
136+
$converter->apply(
137+
["type" => "pseudo", "content" => "not"],
138+
["type" => "pseudospecifier", "content" => ".selected"],
139+
$expression,
140+
true
141+
);
142+
143+
self::assertSame(
144+
".//li[not(contains(concat(' ',normalize-space(@class),' '),' selected '))]",
145+
$expression->toString()
146+
);
147+
}
148+
149+
public function testPseudoSelectorConverterNotSupportsSelectorLists():void {
150+
$converter = new PseudoSelectorConverter();
151+
$expression = new XPathExpression(".//");
152+
$expression->appendElement("li", true);
153+
154+
$converter->apply(
155+
["type" => "pseudo", "content" => "not"],
156+
["type" => "pseudospecifier", "content" => ".selected, [data-state='hidden']"],
157+
$expression,
158+
true
159+
);
160+
161+
self::assertSame(
162+
".//li[not((contains(concat(' ',normalize-space(@class),' '),' selected ') or @data-state=\"hidden\"))]",
163+
$expression->toString()
164+
);
165+
}
166+
167+
public function testPseudoSelectorConverterNotIgnoresUnsupportedComplexSelector():void {
168+
$converter = new PseudoSelectorConverter();
169+
$expression = new XPathExpression(".//");
170+
$expression->appendElement("li", true);
171+
172+
$converter->apply(
173+
["type" => "pseudo", "content" => "not"],
174+
["type" => "pseudospecifier", "content" => "div span"],
175+
$expression,
176+
true
177+
);
178+
179+
self::assertSame(".//li", $expression->toString());
180+
}
181+
128182
public function testSingleSelectorConverterHandlesWildcardClassAndIdSelectors():void {
129183
$converter = new SingleSelectorConverter();
130184

test/phpunit/TranslatorTest.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,65 @@ public function testCheckedPseudoSelector() {
622622
self::assertEquals("input", $checkedEl->tagName);
623623
}
624624

625+
public function testNotPseudoSelectorWithClass() {
626+
$document = new DOMDocument("1.0", "UTF-8");
627+
$document->loadHTML(Helper::HTML_COMPLEX);
628+
$xpath = new DOMXPath($document);
629+
630+
$translator = new Translator("nav li:not(.selected)");
631+
$elements = $xpath->query($translator);
632+
633+
self::assertEquals(3, $elements->length);
634+
}
635+
636+
public function testNotPseudoSelectorWithElementAndAttribute() {
637+
$document = new DOMDocument("1.0", "UTF-8");
638+
$document->loadHTML(Helper::HTML_COMPLEX);
639+
$xpath = new DOMXPath($document);
640+
641+
$notArticle = new Translator("main > :not(article)");
642+
self::assertEquals(1, $xpath->query($notArticle)->length);
643+
644+
$nonEmailInputs = new Translator("input:not([name='email'])");
645+
self::assertEquals(3, $xpath->query($nonEmailInputs)->length);
646+
}
647+
648+
public function testNotPseudoSelectorWithPseudoArgument() {
649+
$document = new DOMDocument("1.0", "UTF-8");
650+
$document->loadHTML(Helper::HTML_COMPLEX);
651+
$xpath = new DOMXPath($document);
652+
653+
$translator = new Translator("input:not(:checked)");
654+
self::assertEquals(3, $xpath->query($translator)->length);
655+
}
656+
657+
public function testNotPseudoSelectorSupportsSelectorLists() {
658+
$document = new DOMDocument("1.0", "UTF-8");
659+
$document->loadHTML(Helper::HTML_COMPLEX);
660+
$xpath = new DOMXPath($document);
661+
662+
$translator = new Translator("nav li:not(.selected, :first-child)");
663+
$elements = $xpath->query($translator);
664+
665+
self::assertEquals(2, $elements->length);
666+
self::assertEquals("About", trim($elements->item(0)->nodeValue));
667+
self::assertEquals("Contact", trim($elements->item(1)->nodeValue));
668+
}
669+
670+
public function testNotPseudoSelectorWithEmptySpecifierIsIgnored() {
671+
$document = new DOMDocument("1.0", "UTF-8");
672+
$document->loadHTML(Helper::HTML_COMPLEX);
673+
$xpath = new DOMXPath($document);
674+
675+
$allInputs = new Translator("input");
676+
$notEmpty = new Translator("input:not()");
677+
678+
self::assertEquals(
679+
$xpath->query($allInputs)->length,
680+
$xpath->query($notEmpty)->length
681+
);
682+
}
683+
625684
public function testCommaSeparatedSelectors() {
626685
// Multiple XPath selectors are separated by a pipe (|), so the CSS selector
627686
// `div, form` should translate to descendant-or-self::div | descendant-or-self::form`

0 commit comments

Comments
 (0)