Skip to content

Commit 957041e

Browse files
committed
feature: not selector classes
1 parent 7ac39ff commit 957041e

File tree

2 files changed

+236
-0
lines changed

2 files changed

+236
-0
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
namespace Gt\CssXPath;
4+
5+
class NotSelectorConditionBuilder {
6+
private ThreadMatcher $threadMatcher;
7+
private AttributeSelectorConverter $attributeSelectorConverter;
8+
9+
public function __construct(
10+
?ThreadMatcher $threadMatcher = null,
11+
?AttributeSelectorConverter $attributeSelectorConverter = null,
12+
) {
13+
$this->threadMatcher = $threadMatcher ?? new ThreadMatcher();
14+
$this->attributeSelectorConverter = $attributeSelectorConverter
15+
?? new AttributeSelectorConverter();
16+
}
17+
18+
public function build(string $selector, bool $htmlMode):?string {
19+
$selector = trim($selector);
20+
if($selector === "") {
21+
return null;
22+
}
23+
24+
$thread = array_values(
25+
$this->threadMatcher->collate(Translator::CSS_REGEX, $selector)
26+
);
27+
if(!$this->isSupportedThread($thread)) {
28+
return null;
29+
}
30+
31+
$token = $thread[0];
32+
$next = $thread[1] ?? null;
33+
return $this->buildConditionFromToken($token, $next, $htmlMode);
34+
}
35+
36+
/** @param array<int, array<string, mixed>> $thread */
37+
private function isSupportedThread(array $thread):bool {
38+
if(empty($thread) || count($thread) > 2) {
39+
return false;
40+
}
41+
42+
foreach($thread as $token) {
43+
if($this->isAxisToken((string)$token["type"])) {
44+
return false;
45+
}
46+
}
47+
48+
return true;
49+
}
50+
51+
private function isAxisToken(string $type):bool {
52+
return in_array($type, [
53+
"descendant",
54+
"child",
55+
"sibling",
56+
"subsequentsibling",
57+
], true);
58+
}
59+
60+
/**
61+
* @param array<string, mixed> $token
62+
* @param array<string, mixed>|null $next
63+
*/
64+
private function buildConditionFromToken(
65+
array $token,
66+
?array $next,
67+
bool $htmlMode
68+
):?string {
69+
return match($token["type"]) {
70+
"element", "star" => $this->buildElementCondition(
71+
(string)$token["content"],
72+
$htmlMode
73+
),
74+
"id" => "@id='" . $token["content"] . "'",
75+
"class" => ""
76+
. "contains(concat(' ',normalize-space(@class),' '),"
77+
. "' " . $token["content"] . " ')",
78+
"attribute" => $this
79+
->attributeSelectorConverter
80+
->buildConditionFromToken($token, $htmlMode),
81+
"pseudo" => $this->buildPseudoCondition($token, $next),
82+
default => null,
83+
};
84+
}
85+
86+
/**
87+
* @param array<string, mixed> $token
88+
* @param array<string, mixed>|null $next
89+
*/
90+
private function buildPseudoCondition(array $token, ?array $next):?string {
91+
$pseudo = (string)$token["content"];
92+
$specifier = $this->extractSpecifier($next);
93+
94+
if(in_array($pseudo, ["disabled", "checked", "selected"], true)) {
95+
return "@{$pseudo}";
96+
}
97+
98+
return match($pseudo) {
99+
"text" => '@type="text"',
100+
"contains" => $specifier !== ""
101+
? "contains(text(),{$specifier})"
102+
: null,
103+
"first-child", "first-of-type" => "position() = 1",
104+
"nth-child", "nth-of-type" => $specifier !== ""
105+
? "position() = {$specifier}"
106+
: null,
107+
"last-child", "last-of-type" => "position() = last()",
108+
default => null,
109+
};
110+
}
111+
112+
private function buildElementCondition(string $name, bool $htmlMode):string {
113+
if($name === "*") {
114+
return "self::*";
115+
}
116+
117+
$element = $htmlMode ? strtolower($name) : $name;
118+
return "self::{$element}";
119+
}
120+
121+
/** @param array<string, mixed>|null $next */
122+
private function extractSpecifier(?array $next):string {
123+
if(!$next || $next["type"] !== "pseudospecifier") {
124+
return "";
125+
}
126+
127+
return (string)$next["content"];
128+
}
129+
}

src/SelectorListSplitter.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace Gt\CssXPath;
4+
5+
class SelectorListSplitter {
6+
/** @return array<int, string> */
7+
public function split(string $selectorList):array {
8+
$selectorList = trim($selectorList);
9+
if($selectorList === "") {
10+
return [];
11+
}
12+
13+
$parts = [];
14+
$current = "";
15+
$quote = null;
16+
$bracketDepth = 0;
17+
$parenDepth = 0;
18+
$length = strlen($selectorList);
19+
20+
for($i = 0; $i < $length; $i++) {
21+
$char = $selectorList[$i];
22+
23+
if($this->handleQuotedState($char, $current, $quote)) {
24+
continue;
25+
}
26+
27+
if($this->openQuoteIfNeeded($char, $current, $quote)) {
28+
continue;
29+
}
30+
31+
$this->trackDepth($char, $bracketDepth, $parenDepth);
32+
if($this->isTopLevelComma($char, $bracketDepth, $parenDepth)) {
33+
$this->appendCurrentPart($parts, $current);
34+
$current = "";
35+
continue;
36+
}
37+
38+
$current .= $char;
39+
}
40+
41+
$this->appendCurrentPart($parts, $current);
42+
return $parts;
43+
}
44+
45+
private function handleQuotedState(
46+
string $char,
47+
string &$current,
48+
?string &$quote
49+
):bool {
50+
if($quote === null) {
51+
return false;
52+
}
53+
54+
$current .= $char;
55+
if($char === $quote) {
56+
$quote = null;
57+
}
58+
59+
return true;
60+
}
61+
62+
private function openQuoteIfNeeded(
63+
string $char,
64+
string &$current,
65+
?string &$quote
66+
):bool {
67+
if($char !== "'" && $char !== '"') {
68+
return false;
69+
}
70+
71+
$quote = $char;
72+
$current .= $char;
73+
return true;
74+
}
75+
76+
private function trackDepth(
77+
string $char,
78+
int &$bracketDepth,
79+
int &$parenDepth
80+
):void {
81+
match($char) {
82+
"[" => $bracketDepth++,
83+
"]" => $bracketDepth = max(0, $bracketDepth - 1),
84+
"(" => $parenDepth++,
85+
")" => $parenDepth = max(0, $parenDepth - 1),
86+
default => null,
87+
};
88+
}
89+
90+
private function isTopLevelComma(
91+
string $char,
92+
int $bracketDepth,
93+
int $parenDepth
94+
):bool {
95+
return $char === ","
96+
&& $bracketDepth === 0
97+
&& $parenDepth === 0;
98+
}
99+
100+
/** @param array<int, string> $parts */
101+
private function appendCurrentPart(array &$parts, string $current):void {
102+
$trimmed = trim($current);
103+
if($trimmed !== "") {
104+
$parts[] = $trimmed;
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)