Przeglądaj źródła

CSSXPath 1.5.0 (#8642)

https://github.com/phpgt/CssXPath/releases/tag/v1.5.0
Alexandre Alapetite 6 dni temu
rodzic
commit
37c8f1f466

+ 1 - 0
lib/.gitignore

@@ -12,6 +12,7 @@ marienfressinaud/lib_opml/tests/
 phpgt/cssxpath/.*
 phpgt/cssxpath/composer.json
 phpgt/cssxpath/CONTRIBUTING.md
+phpgt/cssxpath/phpmd.xml
 phpgt/cssxpath/phpunit.xml
 phpgt/cssxpath/SECURITY*
 phpgt/cssxpath/test/

+ 1 - 1
lib/composer.json

@@ -12,7 +12,7 @@
 	],
 	"require": {
 		"marienfressinaud/lib_opml": "0.5.1",
-		"phpgt/cssxpath": "v1.4.0",
+		"phpgt/cssxpath": "v1.5.0",
 		"phpmailer/phpmailer": "7.0.2",
 		"simplepie/simplepie": "dev-freshrss#6405099830e5383fc2cb9aa1be7a8f42a18cb21c"
 	},

+ 1 - 1
lib/phpgt/cssxpath/README.md

@@ -46,7 +46,7 @@ $document = new DOMDocument();
 $document->loadHTML($html);
 
 $xpath = new DOMXPath($document);
-$inputElementList = $xpath->query(new Translator("form>label>input");
+$inputElementList = $xpath->query(new Translator("form>label>input"));
 ```
 
 ## Using this library with XML Documents

+ 60 - 0
lib/phpgt/cssxpath/phpcs.xml

@@ -0,0 +1,60 @@
+<?xml version="1.0"?>
+<ruleset name="PHP.Gt Standard" namespace="Gt\CS\Standard">
+	<description>Created from PHP.Gt/Styleguide</description>
+	<arg name="extensions" value="php" />
+
+	<rule ref="Generic.Classes.DuplicateClassName" />
+	<rule ref="Generic.Classes.OpeningBraceSameLine" />
+	<rule ref="Generic.CodeAnalysis.EmptyPHPStatement" />
+	<rule ref="Generic.CodeAnalysis.EmptyStatement" />
+	<rule ref="Generic.CodeAnalysis.ForLoopShouldBeWhileLoop" />
+	<rule ref="Generic.CodeAnalysis.ForLoopWithTestFunctionCall" />
+	<rule ref="Generic.CodeAnalysis.JumbledIncrementer" />
+	<rule ref="Generic.CodeAnalysis.UnconditionalIfStatement" />
+	<rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier" />
+	<rule ref="Generic.CodeAnalysis.UnusedFunctionParameter" />
+	<rule ref="Generic.CodeAnalysis.UselessOverridingMethod" />
+	<rule ref="Generic.ControlStructures.InlineControlStructure" />
+	<rule ref="Generic.Files.ByteOrderMark" />
+	<rule ref="Generic.Files.EndFileNewline" />
+	<rule ref="Generic.Files.InlineHTML" />
+	<rule ref="Generic.Files.LineEndings" />
+		<rule ref="Generic.Files.LineLength" />
+	<rule ref="Generic.Files.OneClassPerFile" />
+	<rule ref="Generic.Files.OneInterfacePerFile" />
+	<rule ref="Generic.Files.OneObjectStructurePerFile" />
+	<rule ref="Generic.Files.OneTraitPerFile" />
+	<rule ref="Generic.Formatting.DisallowMultipleStatements" />
+	<rule ref="Generic.Functions.FunctionCallArgumentSpacing" />
+	<rule ref="Generic.Functions.OpeningFunctionBraceKernighanRitchie" />
+	<rule ref="Generic.Metrics.CyclomaticComplexity" />
+	<rule ref="Generic.Metrics.NestingLevel" />
+	<rule ref="Generic.NamingConventions.CamelCapsFunctionName" />
+	<rule ref="Generic.NamingConventions.ConstructorName" />
+	<rule ref="Generic.NamingConventions.UpperCaseConstantName" />
+	<rule ref="Generic.PHP.BacktickOperator" />
+	<rule ref="Generic.PHP.CharacterBeforePHPOpeningTag" />
+	<rule ref="Generic.PHP.DisallowAlternativePHPTags" />
+	<rule ref="Generic.PHP.DisallowRequestSuperglobal" />
+	<rule ref="Generic.PHP.DisallowShortOpenTag" />
+	<rule ref="Generic.PHP.DiscourageGoto" />
+	<rule ref="Generic.PHP.ForbiddenFunctions">
+		<properties>
+			<property name="forbiddenFunctions" type="array">
+				<element key="die" value="null"/>
+				<element key="exit" value="null"/>
+			</property>
+		</properties>
+	</rule>
+	<rule ref="Generic.PHP.LowerCaseConstant" />
+	<rule ref="Generic.PHP.LowerCaseKeyword" />
+	<rule ref="Generic.PHP.LowerCaseType" />
+	<rule ref="Generic.PHP.NoSilencedErrors" />
+	<rule ref="Generic.PHP.Syntax" />
+	<rule ref="Generic.PHP.Syntax" />
+	<rule ref="Generic.VersionControl.GitMergeConflict" />
+	<rule ref="Generic.WhiteSpace.ArbitraryParenthesesSpacing" />
+	<rule ref="Generic.WhiteSpace.DisallowSpaceIndent" />
+	<rule ref="Generic.WhiteSpace.IncrementDecrementSpacing" />
+	<rule ref="Generic.WhiteSpace.LanguageConstructSpacing" />
+</ruleset>

+ 127 - 0
lib/phpgt/cssxpath/src/AttributeSelectorConverter.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class AttributeSelectorConverter {
+	/** @param array<string, mixed> $token */
+	public function apply(
+		array $token,
+		XPathExpression $expression,
+		bool $htmlMode
+	):void {
+		$expression->ensureElement();
+
+		$attribute = (string)$token["content"];
+		if($htmlMode) {
+			$attribute = strtolower($attribute);
+		}
+
+		$detail = $token["detail"] ?? null;
+		$detailType = $detail[0] ?? null;
+		$detailValue = $detail[1] ?? null;
+
+		if(!$this->hasEqualsType($detailType)) {
+			$expression->appendFragment("[@{$attribute}]");
+			return;
+		}
+
+		$valueString = trim((string)$detailValue["content"], " '\"");
+		$equalsType = $detailType["content"];
+		$expression->appendFragment(
+			$this->buildExpression($attribute, $valueString, $equalsType)
+		);
+	}
+
+	/** @param array<string, mixed> $token */
+	public function buildConditionFromToken(array $token, bool $htmlMode):string {
+		$parts = $this->extractTokenParts($token, $htmlMode);
+		return $this->buildConditionFromParts(
+			$parts["attribute"],
+			$parts["detailType"],
+			$parts["detailValue"],
+		);
+	}
+
+	/**
+	 * @param array<string, mixed>|null $detailType
+	 * @param array<string, mixed>|null $detailValue
+	 */
+	private function buildConditionFromParts(
+		string $attribute,
+		?array $detailType,
+		?array $detailValue,
+	):string {
+		if(!$this->hasEqualsType($detailType)) {
+			return "@{$attribute}";
+		}
+
+		$valueString = trim((string)$detailValue["content"], " '\"");
+		$equalsType = $detailType["content"];
+		return $this->buildCondition($attribute, $valueString, $equalsType);
+	}
+
+	/**
+	 * @param array<string, mixed> $token
+	 * @return array{
+	 *   attribute: string,
+	 *   detailType: array<string, mixed>|null,
+	 *   detailValue: array<string, mixed>|null
+	 * }
+	 */
+	private function extractTokenParts(array $token, bool $htmlMode):array {
+		$attribute = (string)$token["content"];
+		if($htmlMode) {
+			$attribute = strtolower($attribute);
+		}
+
+		$detail = $token["detail"] ?? null;
+		return [
+			"attribute" => $attribute,
+			"detailType" => $detail[0] ?? null,
+			"detailValue" => $detail[1] ?? null,
+		];
+	}
+
+	/** @param array<string, mixed>|null $detailType */
+	private function hasEqualsType(?array $detailType):bool {
+		return isset($detailType["type"])
+			&& $detailType["type"] === "attribute_equals";
+	}
+
+	private function buildCondition(
+		string $attribute,
+		string $value,
+		string $equalsType
+	):string {
+		return match($equalsType) {
+			Translator::EQUALS_EXACT => "@{$attribute}=\"{$value}\"",
+			Translator::EQUALS_CONTAINS => "contains(@{$attribute},\"{$value}\")",
+			Translator::EQUALS_CONTAINS_WORD => ""
+				. "contains(concat(\" \",@{$attribute},\" \"),"
+				. "concat(\" \",\"{$value}\",\" \"))"
+				. "",
+			Translator::EQUALS_OR_STARTS_WITH_HYPHENATED => ""
+				. "@{$attribute}=\"{$value}\" or "
+				. "starts-with(@{$attribute}, \"{$value}-\")"
+				. "",
+			Translator::EQUALS_STARTS_WITH => ""
+				. "starts-with(@{$attribute}, \"{$value}\")"
+				. "",
+				Translator::EQUALS_ENDS_WITH => ""
+				. "substring(@{$attribute},"
+				. "string-length(@{$attribute}) - "
+				. "string-length(\"{$value}\") + 1)"
+				. "=\"{$value}\""
+				. "",
+			default => "@{$attribute}",
+		};
+	}
+
+	private function buildExpression(
+		string $attribute,
+		string $value,
+		string $equalsType
+	):string {
+		return "[" . $this->buildCondition($attribute, $value, $equalsType) . "]";
+	}
+}

+ 113 - 0
lib/phpgt/cssxpath/src/CssAttributeTokenBuilder.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class CssAttributeTokenBuilder {
+	/**
+	 * @return array<string, mixed>
+	 */
+	public function build(string $content, ?callable $transform):array {
+		$operatorData = $this->extractOperator($content);
+		$token = $this->buildMatchPayload(
+			"attribute",
+			$operatorData["name"],
+			$transform
+		);
+
+		if($operatorData["operator"] === null) {
+			return $token;
+		}
+
+		$token["detail"] = [
+			$this->buildMatchPayload(
+				"attribute_equals",
+				$operatorData["operator"],
+				$transform
+			),
+			$this->buildMatchPayload(
+				"attribute_value",
+				$operatorData["value"],
+				$transform
+			),
+		];
+		return $token;
+	}
+
+	/**
+	 * @return array{name: string, operator: string|null, value: string}
+	 */
+	private function extractOperator(string $content):array {
+		$operators = ["~=", "$=", "|=", "^=", "*=", "="];
+		$quote = null;
+		$length = strlen($content);
+
+		for($index = 0; $index < $length; $index++) {
+			$char = $content[$index];
+			if($quote !== null) {
+				if($char === $quote) {
+					$quote = null;
+				}
+
+				continue;
+			}
+
+			if($char === "'" || $char === '"') {
+				$quote = $char;
+				continue;
+			}
+
+			$matchedOperator = $this->matchOperator(
+				$content,
+				$index,
+				$operators
+			);
+			if($matchedOperator === null) {
+				continue;
+			}
+
+			return [
+				"name" => trim(substr($content, 0, $index)),
+				"operator" => $matchedOperator,
+				"value" => trim(
+					substr($content, $index + strlen($matchedOperator))
+				),
+			];
+		}
+
+		return [
+			"name" => trim($content),
+			"operator" => null,
+			"value" => "",
+		];
+	}
+
+	/**
+	 * @param array<int, string> $operators
+	 */
+	private function matchOperator(
+		string $content,
+		int $index,
+		array $operators
+	):?string {
+		foreach($operators as $operator) {
+			if(substr($content, $index, strlen($operator)) === $operator) {
+				return $operator;
+			}
+		}
+
+		return null;
+	}
+
+	/** @return array<string, string> */
+	private function buildMatchPayload(
+		string $groupKey,
+		string $match,
+		?callable $transform
+	):array {
+		if($transform) {
+			return $transform($groupKey, $match);
+		}
+
+		return ["type" => $groupKey, "content" => $match];
+	}
+}

+ 371 - 0
lib/phpgt/cssxpath/src/CssSelectorLexer.php

@@ -0,0 +1,371 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class CssSelectorLexer {
+	private CssAttributeTokenBuilder $attributeTokenBuilder;
+
+	public function __construct(
+		?CssAttributeTokenBuilder $attributeTokenBuilder = null
+	) {
+		$this->attributeTokenBuilder = $attributeTokenBuilder
+			?? new CssAttributeTokenBuilder();
+	}
+
+	/** @return array<int, array<string, mixed>> */
+	public function lex(string $selector, ?callable $transform):array {
+		$tokens = [];
+		$length = strlen($selector);
+
+		for($index = 0; $index < $length;) {
+			$char = $selector[$index];
+
+			if(ctype_space($char)) {
+				$index = $this->consumeWhitespace(
+					$selector,
+					$index,
+					$tokens,
+					$transform
+				);
+				continue;
+			}
+
+			$index = $this->consumeToken(
+				$selector,
+				$index,
+				$char,
+				$tokens,
+				$transform
+			);
+		}
+
+		return $tokens;
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>> $tokens
+	 */
+	private function consumeToken(
+		string $selector,
+		int $index,
+		string $char,
+		array &$tokens,
+		?callable $transform
+	):int {
+		return match($char) {
+			"*" => $this->consumeSimpleToken("star", "*", $index, $tokens, $transform),
+			">" => $this->consumeSimpleToken("child", ">", $index, $tokens, $transform),
+			"+" => $this->consumeSimpleToken(
+				"sibling",
+				"+",
+				$index,
+				$tokens,
+				$transform
+			),
+			"~" => $this->consumeSimpleToken(
+				"subsequentsibling",
+				"~",
+				$index,
+				$tokens,
+				$transform
+			),
+			"#" => $this->consumeIdentifierToken(
+				"id",
+				$selector,
+				$index + 1,
+				$tokens,
+				$transform
+			),
+			"." => $this->consumeIdentifierToken(
+				"class",
+				$selector,
+				$index + 1,
+				$tokens,
+				$transform
+			),
+			":" => $this->consumePseudoToken($selector, $index, $tokens, $transform),
+			"[" => $this->consumeAttributeToken($selector, $index, $tokens, $transform),
+			default => $this->consumeDefaultToken(
+				$selector,
+				$index,
+				$char,
+				$tokens,
+				$transform
+			),
+		};
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>> $tokens
+	 */
+	private function consumeSimpleToken(
+		string $type,
+		string $content,
+		int $index,
+		array &$tokens,
+		?callable $transform
+	):int {
+		$tokens[] = $this->buildMatchPayload($type, $content, $transform);
+		return $index + 1;
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>> $tokens
+	 */
+	private function consumeIdentifierToken(
+		string $type,
+		string $selector,
+		int $index,
+		array &$tokens,
+		?callable $transform
+	):int {
+		[$identifier, $nextIndex] = $this->readIdentifier($selector, $index);
+		$tokens[] = $this->buildMatchPayload($type, $identifier, $transform);
+		return $nextIndex;
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>> $tokens
+	 */
+	private function consumePseudoToken(
+		string $selector,
+		int $index,
+		array &$tokens,
+		?callable $transform
+	):int {
+		[$pseudoTokens, $nextIndex] = $this->readPseudo(
+			$selector,
+			$index,
+			$transform
+		);
+		array_push($tokens, ...$pseudoTokens);
+		return $nextIndex;
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>> $tokens
+	 */
+	private function consumeAttributeToken(
+		string $selector,
+		int $index,
+		array &$tokens,
+		?callable $transform
+	):int {
+		[$attributeToken, $nextIndex] = $this->readAttribute(
+			$selector,
+			$index,
+			$transform
+		);
+		$tokens[] = $attributeToken;
+		return $nextIndex;
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>> $tokens
+	 */
+	private function consumeDefaultToken(
+		string $selector,
+		int $index,
+		string $char,
+		array &$tokens,
+		?callable $transform
+	):int {
+		if(!$this->isIdentifierCharacter($char)) {
+			return $index + 1;
+		}
+
+		return $this->consumeIdentifierToken(
+			"element",
+			$selector,
+			$index,
+			$tokens,
+			$transform
+		);
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>> $tokens
+	 */
+	private function consumeWhitespace(
+		string $selector,
+		int $index,
+		array &$tokens,
+		?callable $transform
+	):int {
+		$length = strlen($selector);
+		$nextIndex = $index;
+		while($nextIndex < $length && ctype_space($selector[$nextIndex])) {
+			$nextIndex++;
+		}
+
+		if($this->shouldEmitDescendantToken($selector, $tokens, $nextIndex)) {
+			$tokens[] = $this->buildMatchPayload("descendant", " ", $transform);
+		}
+
+		return $nextIndex;
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>> $tokens
+	 */
+	private function shouldEmitDescendantToken(
+		string $selector,
+		array $tokens,
+		int $nextIndex
+	):bool {
+		if(empty($tokens) || !isset($selector[$nextIndex])) {
+			return false;
+		}
+
+		$nextChar = $selector[$nextIndex];
+		if(in_array($nextChar, [">", "+", "~", ",", ")"], true)) {
+			return false;
+		}
+
+		$previousType = (string)$tokens[array_key_last($tokens)]["type"];
+		return !in_array($previousType, [
+			"child",
+			"sibling",
+			"subsequentsibling",
+			"descendant",
+		], true);
+	}
+
+	/** @return array{0: string, 1: int} */
+	private function readIdentifier(string $selector, int $index):array {
+		$length = strlen($selector);
+		$identifier = "";
+
+		while($index < $length && $this->isIdentifierCharacter($selector[$index])) {
+			$identifier .= $selector[$index];
+			$index++;
+		}
+
+		return [$identifier, $index];
+	}
+
+	/**
+	 * @return array{0: array<int, array<string, mixed>>, 1: int}
+	 */
+	private function readPseudo(
+		string $selector,
+		int $index,
+		?callable $transform
+	):array {
+		$tokens = [];
+		$isPseudoElement = isset($selector[$index + 1])
+			&& $selector[$index + 1] === ":";
+		$nameStart = $index + ($isPseudoElement ? 2 : 1);
+		[$name, $nextIndex] = $this->readIdentifier($selector, $nameStart);
+
+		$tokens[] = $this->buildMatchPayload(
+			$isPseudoElement ? "pseudo-element" : "pseudo",
+			$name,
+			$transform
+		);
+
+		if(isset($selector[$nextIndex]) && $selector[$nextIndex] === "(") {
+			[$content, $nextIndex] = $this->readBalancedContent(
+				$selector,
+				$nextIndex,
+				"(",
+				")"
+			);
+			$tokens[] = $this->buildMatchPayload(
+				"pseudospecifier",
+				$content,
+				$transform
+			);
+		}
+
+		return [$tokens, $nextIndex];
+	}
+
+	/**
+	 * @return array{0: array<string, mixed>, 1: int}
+	 */
+	private function readAttribute(
+		string $selector,
+		int $index,
+		?callable $transform
+	):array {
+		[$content, $nextIndex] = $this->readBalancedContent(
+			$selector,
+			$index,
+			"[",
+			"]"
+		);
+		return [
+			$this->attributeTokenBuilder->build($content, $transform),
+			$nextIndex,
+		];
+	}
+
+	/** @return array{0: string, 1: int} */
+	private function readBalancedContent(
+		string $selector,
+		int $startIndex,
+		string $open,
+		string $close
+	):array {
+		$length = strlen($selector);
+		$depth = 1;
+		$content = "";
+		$quote = null;
+
+		for($index = $startIndex + 1; $index < $length; $index++) {
+			$char = $selector[$index];
+
+			if($quote !== null) {
+				$content .= $char;
+				if($char === $quote) {
+					$quote = null;
+				}
+				continue;
+			}
+
+			if($char === "'" || $char === '"') {
+				$quote = $char;
+				$content .= $char;
+				continue;
+			}
+
+			if($char === $open) {
+				$depth++;
+				$content .= $char;
+				continue;
+			}
+
+			if($char === $close) {
+				$depth--;
+				if($depth === 0) {
+					return [$content, $index + 1];
+				}
+
+				$content .= $char;
+				continue;
+			}
+
+			$content .= $char;
+		}
+
+		return [$content, $length];
+	}
+
+	private function isIdentifierCharacter(string $char):bool {
+		return preg_match('/[\w-]/', $char) === 1;
+	}
+
+	/** @return array<string, string> */
+	private function buildMatchPayload(
+		string $groupKey,
+		string $match,
+		?callable $transform
+	):array {
+		if($transform) {
+			return $transform($groupKey, $match);
+		}
+
+		return ["type" => $groupKey, "content" => $match];
+	}
+}

+ 81 - 0
lib/phpgt/cssxpath/src/HasSelectorConditionBuilder.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class HasSelectorConditionBuilder {
+	private SelectorListSplitter $selectorListSplitter;
+	private SingleSelectorConverter $singleSelectorConverter;
+
+	public function __construct(
+		?SelectorListSplitter $selectorListSplitter = null,
+		?SingleSelectorConverter $singleSelectorConverter = null,
+	) {
+		$this->selectorListSplitter = $selectorListSplitter
+			?? new SelectorListSplitter();
+		$this->singleSelectorConverter = $singleSelectorConverter
+			?? new SingleSelectorConverter();
+	}
+
+	public function build(string $selectorList, bool $htmlMode):?string {
+		$selectorList = trim($selectorList);
+		if($selectorList === "") {
+			return null;
+		}
+
+		$this->assertSupported($selectorList);
+
+		$selectors = $this->selectorListSplitter->split($selectorList);
+		if(empty($selectors)) {
+			return null;
+		}
+
+		$conditions = [];
+		foreach($selectors as $selector) {
+			$conditions[] = $this->buildCondition(trim($selector), $htmlMode);
+		}
+
+		if(count($conditions) === 1) {
+			return $conditions[0];
+		}
+
+		$wrappedConditions = array_map(
+			fn(string $condition):string => "({$condition})",
+			$conditions
+		);
+		return implode(" or ", $wrappedConditions);
+	}
+
+	private function buildCondition(string $selector, bool $htmlMode):string {
+		$prefix = str_starts_with($selector, ">")
+			|| str_starts_with($selector, "+")
+			|| str_starts_with($selector, "~")
+			? "."
+			: ".//";
+
+		return $this->singleSelectorConverter->convert(
+			$selector,
+			$prefix,
+			$htmlMode
+		);
+	}
+
+	private function assertSupported(string $selectorList):void {
+		if(preg_match('/(^|[^[:alnum:]_-]):has\s*\(/', $selectorList) === 1) {
+			throw new NotYetImplementedException(
+				"Nested :has selector functionality is deferred"
+			);
+		}
+
+		if(str_contains($selectorList, "::")) {
+			throw new NotYetImplementedException(
+				"Pseudo-element :has selector functionality is deferred"
+			);
+		}
+
+		if(preg_match('/:nth-child\([^)]*\bof\b/', $selectorList) === 1) {
+			throw new NotYetImplementedException(
+				"':nth-child(of S)' in :has selector functionality is deferred"
+			);
+		}
+	}
+}

+ 154 - 0
lib/phpgt/cssxpath/src/NotSelectorConditionBuilder.php

@@ -0,0 +1,154 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class NotSelectorConditionBuilder {
+	private ThreadMatcher $threadMatcher;
+	private AttributeSelectorConverter $attributeSelectorConverter;
+
+	public function __construct(
+		?ThreadMatcher $threadMatcher = null,
+		?AttributeSelectorConverter $attributeSelectorConverter = null,
+	) {
+		$this->threadMatcher = $threadMatcher ?? new ThreadMatcher();
+		$this->attributeSelectorConverter = $attributeSelectorConverter
+			?? new AttributeSelectorConverter();
+	}
+
+	public function build(string $selector, bool $htmlMode):?string {
+		$selector = trim($selector);
+		if($selector === "") {
+			return null;
+		}
+
+		$thread = array_values(
+			$this->threadMatcher->collate(Translator::CSS_REGEX, $selector)
+		);
+		if(!$this->isSupportedThread($thread)) {
+			return null;
+		}
+
+		$token = $thread[0];
+		$next = $thread[1] ?? null;
+		return $this->buildConditionFromToken($token, $next, $htmlMode);
+	}
+
+	/** @param array<int, array<string, mixed>> $thread */
+	private function isSupportedThread(array $thread):bool {
+		if(empty($thread) || count($thread) > 2) {
+			return false;
+		}
+
+		foreach($thread as $token) {
+			if($this->isAxisToken((string)$token["type"])) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	private function isAxisToken(string $type):bool {
+		return in_array($type, [
+			"descendant",
+			"child",
+			"sibling",
+			"subsequentsibling",
+		], true);
+	}
+
+	/**
+	 * @param array<string, mixed> $token
+	 * @param array<string, mixed>|null $next
+	 */
+	private function buildConditionFromToken(
+		array $token,
+		?array $next,
+		bool $htmlMode
+	):?string {
+		$type = (string)$token["type"];
+		if($this->isElementType($type)) {
+			return $this->buildElementCondition(
+				(string)$token["content"],
+				$htmlMode
+			);
+		}
+
+		return $this->buildNonElementCondition($type, $token, $next, $htmlMode);
+	}
+
+	private function isElementType(string $type):bool {
+		return in_array($type, ["element", "star"], true);
+	}
+
+	/**
+	 * @param array<string, mixed> $token
+	 * @param array<string, mixed>|null $next
+	 */
+	private function buildNonElementCondition(
+		string $type,
+		array $token,
+		?array $next,
+		bool $htmlMode
+	):?string {
+		return match($type) {
+			"id" => "@id='" . $token["content"] . "'",
+			"class" => $this->buildClassCondition((string)$token["content"]),
+			"attribute" => $this
+				->attributeSelectorConverter
+				->buildConditionFromToken($token, $htmlMode),
+			"pseudo" => $this->buildPseudoCondition($token, $next),
+			default => null,
+		};
+	}
+
+	private function buildClassCondition(string $className):string {
+		return ""
+			. "contains(concat(' ',normalize-space(@class),' '),"
+			. "' {$className} ')";
+	}
+
+	/**
+	 * @param array<string, mixed> $token
+	 * @param array<string, mixed>|null $next
+	 */
+	private function buildPseudoCondition(array $token, ?array $next):?string {
+		$pseudo = (string)$token["content"];
+		$specifier = $this->extractSpecifier($next);
+
+		if(in_array($pseudo, ["disabled", "checked", "selected"], true)) {
+			return "@{$pseudo}";
+		}
+
+		return match($pseudo) {
+			"text" => '@type="text"',
+			"contains" => $specifier !== ""
+				? "contains(text(),{$specifier})"
+				: null,
+			"first-child", "first-of-type" => "position() = 1",
+			"nth-child", "nth-of-type" => $specifier !== ""
+				? "position() = {$specifier}"
+				: null,
+			"last-child", "last-of-type" => "position() = last()",
+			default => null,
+		};
+	}
+
+	private function buildElementCondition(string $name, bool $htmlMode):string {
+		if($name === "*") {
+			return "self::*";
+		}
+
+		$element = $htmlMode ? strtolower($name) : $name;
+		return "self::{$element}";
+	}
+
+	/** @param array<string, mixed>|null $next */
+	private function extractSpecifier(?array $next):string {
+		if(!$next || $next["type"] !== "pseudospecifier") {
+			return "";
+		}
+
+		return (string)$next["content"];
+	}
+}

+ 160 - 0
lib/phpgt/cssxpath/src/PseudoSelectorConverter.php

@@ -0,0 +1,160 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class PseudoSelectorConverter {
+	/** @var array<int, string> */
+	private const BOOLEAN_ATTRIBUTES = ["disabled", "checked", "selected"];
+	private SelectorListSplitter $selectorListSplitter;
+	private NotSelectorConditionBuilder $notSelectorConditionBuilder;
+	private ?HasSelectorConditionBuilder $hasSelectorConditionBuilder;
+
+	public function __construct(
+		?SelectorListSplitter $selectorListSplitter = null,
+		?NotSelectorConditionBuilder $notSelectorConditionBuilder = null,
+		?HasSelectorConditionBuilder $hasSelectorConditionBuilder = null,
+	) {
+		$this->selectorListSplitter = $selectorListSplitter
+			?? new SelectorListSplitter();
+		$this->notSelectorConditionBuilder = $notSelectorConditionBuilder
+			?? new NotSelectorConditionBuilder();
+		$this->hasSelectorConditionBuilder = $hasSelectorConditionBuilder;
+	}
+
+	/**
+	 * @param array<string, mixed> $token
+	 * @param array<string, mixed>|null $next
+	 */
+	public function apply(
+		array $token,
+		?array $next,
+		XPathExpression $expression,
+		bool $htmlMode
+	):void {
+		$pseudo = $token["content"];
+		$specifier = $this->extractSpecifier($next);
+
+		if(in_array($pseudo, self::BOOLEAN_ATTRIBUTES, true)) {
+			$expression->appendFragment("[@{$pseudo}]");
+			return;
+		}
+
+		$handlers = [
+			"text" => fn() => $this->applyText($expression),
+			"contains" => fn() => $this->applyContains($expression, $specifier),
+			"not" => fn() => $this->applyNot($expression, $specifier, $htmlMode),
+			"has" => fn() => $this->applyHas($expression, $specifier, $htmlMode),
+			"first-child" => fn() => $expression->prependToLast("*[1]/self::"),
+			"nth-child" => fn() => $this->applyNthChild($expression, $specifier),
+			"last-child" => fn() => $expression->prependToLast("*[last()]/self::"),
+			"first-of-type" => fn() => $expression->appendFragment("[1]"),
+			"nth-of-type" => fn() => $this->applyNthOfType($expression, $specifier),
+			"last-of-type" => fn() => $expression->appendFragment("[last()]"),
+		];
+
+		$handler = $handlers[$pseudo] ?? null;
+		if($handler !== null) {
+			$handler();
+		}
+	}
+
+	private function applyText(XPathExpression $expression):void {
+		$expression->appendFragment('[@type="text"]');
+	}
+
+	private function applyContains(
+		XPathExpression $expression,
+		string $specifier
+	):void {
+		if($specifier === "") {
+			return;
+		}
+
+		$expression->appendFragment("[contains(text(),{$specifier})]");
+	}
+
+	private function applyNthChild(
+		XPathExpression $expression,
+		string $specifier
+	):void {
+		if($specifier === "") {
+			return;
+		}
+
+		if($expression->lastPartEndsWith("]")) {
+			$replacement = " and position() = {$specifier}]";
+			$expression->replaceInLast("]", $replacement);
+			return;
+		}
+
+		$expression->appendFragment("[{$specifier}]");
+	}
+
+	private function applyNthOfType(
+		XPathExpression $expression,
+		string $specifier
+	):void {
+		if($specifier === "") {
+			return;
+		}
+
+		$expression->appendFragment("[{$specifier}]");
+	}
+
+	private function applyNot(
+		XPathExpression $expression,
+		string $specifier,
+		bool $htmlMode
+	):void {
+		$selectorList = $this->selectorListSplitter->split($specifier);
+		if(empty($selectorList)) {
+			return;
+		}
+
+		$conditions = [];
+		foreach($selectorList as $selector) {
+			$condition = $this->notSelectorConditionBuilder
+				->build($selector, $htmlMode);
+			if($condition === null) {
+				return;
+			}
+
+			$conditions[] = $condition;
+		}
+
+		$combined = count($conditions) === 1
+			? $conditions[0]
+			: "(" . implode(" or ", $conditions) . ")";
+		$expression->ensureElement();
+		$expression->appendFragment("[not({$combined})]");
+	}
+
+	private function applyHas(
+		XPathExpression $expression,
+		string $specifier,
+		bool $htmlMode
+	):void {
+		$condition = $this->getHasSelectorConditionBuilder()
+			->build($specifier, $htmlMode);
+		if($condition === null) {
+			return;
+		}
+
+		$expression->ensureElement();
+		$expression->appendFragment("[{$condition}]");
+	}
+
+	private function getHasSelectorConditionBuilder():HasSelectorConditionBuilder {
+		return $this->hasSelectorConditionBuilder
+			??= new HasSelectorConditionBuilder();
+	}
+
+	/** @param array<string, mixed>|null $next */
+	private function extractSpecifier(?array $next):string {
+		if(!$next || $next["type"] !== "pseudospecifier") {
+			return "";
+		}
+
+		return (string)$next["content"];
+	}
+}

+ 107 - 0
lib/phpgt/cssxpath/src/SelectorListSplitter.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class SelectorListSplitter {
+	/** @return array<int, string> */
+	public function split(string $selectorList):array {
+		$selectorList = trim($selectorList);
+		if($selectorList === "") {
+			return [];
+		}
+
+		$parts = [];
+		$current = "";
+		$quote = null;
+		$bracketDepth = 0;
+		$parenDepth = 0;
+		$length = strlen($selectorList);
+
+		for($i = 0; $i < $length; $i++) {
+			$char = $selectorList[$i];
+
+			if($this->handleQuotedState($char, $current, $quote)) {
+				continue;
+			}
+
+			if($this->openQuoteIfNeeded($char, $current, $quote)) {
+				continue;
+			}
+
+			$this->trackDepth($char, $bracketDepth, $parenDepth);
+			if($this->isTopLevelComma($char, $bracketDepth, $parenDepth)) {
+				$this->appendCurrentPart($parts, $current);
+				$current = "";
+				continue;
+			}
+
+			$current .= $char;
+		}
+
+		$this->appendCurrentPart($parts, $current);
+		return $parts;
+	}
+
+	private function handleQuotedState(
+		string $char,
+		string &$current,
+		?string &$quote
+	):bool {
+		if($quote === null) {
+			return false;
+		}
+
+		$current .= $char;
+		if($char === $quote) {
+			$quote = null;
+		}
+
+		return true;
+	}
+
+	private function openQuoteIfNeeded(
+		string $char,
+		string &$current,
+		?string &$quote
+	):bool {
+		if($char !== "'" && $char !== '"') {
+			return false;
+		}
+
+		$quote = $char;
+		$current .= $char;
+		return true;
+	}
+
+	private function trackDepth(
+		string $char,
+		int &$bracketDepth,
+		int &$parenDepth
+	):void {
+		match($char) {
+			"[" => $bracketDepth++,
+			"]" => $bracketDepth = max(0, $bracketDepth - 1),
+			"(" => $parenDepth++,
+			")" => $parenDepth = max(0, $parenDepth - 1),
+			default => null,
+		};
+	}
+
+	private function isTopLevelComma(
+		string $char,
+		int $bracketDepth,
+		int $parenDepth
+	):bool {
+		return $char === ","
+			&& $bracketDepth === 0
+			&& $parenDepth === 0;
+	}
+
+	/** @param array<int, string> $parts */
+	private function appendCurrentPart(array &$parts, string $current):void {
+		$trimmed = trim($current);
+		if($trimmed !== "") {
+			$parts[] = $trimmed;
+		}
+	}
+}

+ 104 - 0
lib/phpgt/cssxpath/src/SingleSelectorConverter.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class SingleSelectorConverter {
+	private ThreadMatcher $threadMatcher;
+	private PseudoSelectorConverter $pseudoSelectorConverter;
+	private AttributeSelectorConverter $attributeSelectorConverter;
+
+	public function __construct(
+		?ThreadMatcher $threadMatcher = null,
+		?PseudoSelectorConverter $pseudoSelectorConverter = null,
+		?AttributeSelectorConverter $attributeSelectorConverter = null,
+	) {
+		$this->threadMatcher = $threadMatcher ?? new ThreadMatcher();
+		$this->pseudoSelectorConverter = $pseudoSelectorConverter
+			?? new PseudoSelectorConverter();
+		$this->attributeSelectorConverter = $attributeSelectorConverter
+			?? new AttributeSelectorConverter();
+	}
+
+	public function convert(
+		string $css,
+		string $prefix,
+		bool $htmlMode
+	):string {
+		$thread = array_values(
+			array_filter(
+				$this->threadMatcher->collate(Translator::CSS_REGEX, $css)
+			)
+		);
+		$expression = new XPathExpression($prefix);
+
+		foreach($thread as $index => $token) {
+			$next = $thread[$index + 1] ?? null;
+			$this->applyToken($token, $next, $expression, $htmlMode);
+		}
+
+		return $expression->toString();
+	}
+
+	/**
+	 * @param array<string, mixed> $token
+	 * @param array<string, mixed>|null $next
+	 */
+	private function applyToken(
+		array $token,
+		?array $next,
+		XPathExpression $expression,
+		bool $htmlMode
+	):void {
+		$handlers = [
+			"star" => fn() => $expression
+				->appendElement((string)$token["content"], $htmlMode),
+				"element" => fn() => $expression
+					->appendElement((string)$token["content"], $htmlMode),
+				"pseudo" => fn() => $this->pseudoSelectorConverter
+					->apply($token, $next, $expression, $htmlMode),
+				"child" => fn() => $this->appendAxis($expression, "/"),
+			"id" => fn() => $this->appendId($expression, (string)$token["content"]),
+			"class" => fn() => $this
+				->appendClass($expression, (string)$token["content"]),
+			"sibling" => fn() => $this->appendAxis(
+				$expression,
+				"/following-sibling::*[1]/self::"
+			),
+			"subsequentsibling" => fn() => $this->appendAxis(
+				$expression,
+				"/following-sibling::"
+			),
+			"attribute" => fn() => $this->attributeSelectorConverter
+				->apply($token, $expression, $htmlMode),
+			"descendant" => fn() => $this->appendAxis($expression, "//"),
+		];
+
+		$handler = $handlers[$token["type"]] ?? null;
+		if($handler !== null) {
+			$handler();
+		}
+	}
+
+	private function appendAxis(XPathExpression $expression, string $axis):void {
+		$expression->appendFragment($axis);
+		$expression->markElementMissing();
+	}
+
+	private function appendId(
+		XPathExpression $expression,
+		string $identifier
+	):void {
+		$expression->ensureElement();
+		$expression->appendFragment("[@id='{$identifier}']");
+	}
+
+	private function appendClass(
+		XPathExpression $expression,
+		string $className
+	):void {
+		$expression->ensureElement();
+		$expression->appendFragment(
+			"[contains(concat(' ',normalize-space(@class),' '),' {$className} ')]"
+		);
+	}
+}

+ 118 - 0
lib/phpgt/cssxpath/src/ThreadMatcher.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class ThreadMatcher {
+	private CssSelectorLexer $cssSelectorLexer;
+
+	public function __construct(
+		?CssSelectorLexer $cssSelectorLexer = null
+	) {
+		$this->cssSelectorLexer = $cssSelectorLexer
+			?? new CssSelectorLexer();
+	}
+
+	/** @return array<int, array<string, mixed>> */
+	public function collate(
+		string $regex,
+		string $string,
+		?callable $transform = null
+	):array {
+		if($regex === Translator::CSS_REGEX) {
+			return $this->collateCssSelector($string, $transform);
+		}
+
+		preg_match_all(
+			$regex,
+			$string,
+			$matches,
+			PREG_PATTERN_ORDER
+		);
+
+		$set = $this->initialiseSet($matches[0]);
+
+		foreach($matches as $key => $matchedGroup) {
+			if(is_numeric($key)) {
+				continue;
+			}
+
+			$this->collateGroup($set, $key, $matchedGroup, $transform);
+		}
+
+		return $set;
+	}
+
+	/** @return array<int, array<string, mixed>> */
+	private function collateCssSelector(
+		string $selector,
+		?callable $transform
+	):array {
+		return $this->cssSelectorLexer->lex($selector, $transform);
+	}
+
+	/**
+	 * @param array<int, string> $matches
+	 * @return array<int, array<string, mixed>|null>
+	 */
+	private function initialiseSet(array $matches):array {
+		$set = [];
+
+		foreach($matches as $index => $value) {
+			if($value !== "") {
+				$set[$index] = null;
+			}
+		}
+
+		return $set;
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>|null> $set
+	 * @param array<int, string> $matchedGroup
+	 */
+	private function collateGroup(
+		array &$set,
+		string $groupKey,
+		array $matchedGroup,
+		?callable $transform
+	):void {
+		foreach($matchedGroup as $index => $match) {
+			if($match === "") {
+				continue;
+			}
+
+			$toSet = $this->buildMatchPayload($groupKey, $match, $transform);
+			$this->appendMatch($set, $index, $toSet);
+		}
+	}
+
+	/** @return array<string, string> */
+	private function buildMatchPayload(
+		string $groupKey,
+		string $match,
+		?callable $transform
+	):array {
+		if($transform) {
+			return $transform($groupKey, $match);
+		}
+
+		return ["type" => $groupKey, "content" => $match];
+	}
+
+	/**
+	 * @param array<int, array<string, mixed>|null> $set
+	 * @param array<string, string> $toSet
+	 */
+	private function appendMatch(array &$set, int $index, array $toSet):void {
+		if(!isset($set[$index])) {
+			$set[$index] = $toSet;
+			return;
+		}
+
+		if(!isset($set[$index]["detail"])) {
+			$set[$index]["detail"] = [];
+		}
+
+		$set[$index]["detail"][] = $toSet;
+	}
+}

+ 34 - 363
lib/phpgt/cssxpath/src/Translator.php

@@ -1,402 +1,73 @@
-<?php /** @noinspection HtmlDeprecatedTag */
+<?php
+
 namespace Gt\CssXPath;
 
 class Translator {
-	const cssRegex =
+	public const CSS_REGEX =
 		'/'
 		. '(?P<star>\*)'
 		. '|(:(?P<pseudo>[\w-]*))'
-		. '|\(*(?P<pseudospecifier>["\']*[\w\h-]*["\']*)\)'
+		. '|\((?P<pseudospecifier>[^)]*)\)'
 		. '|(?P<element>[\w-]*)'
 		. '|(?P<child>\s*>\s*)'
 		. '|(#(?P<id>[\w-]*))'
 		. '|(\.(?P<class>[\w-]*))'
 		. '|(?P<sibling>\s*\+\s*)'
 		. '|(?P<subsequentsibling>\s*~\s*)'
-		. "|(\[(?P<attribute>[\w-]*)((?P<attribute_equals>[=~$|^*]+)(?P<attribute_value>(.+\[\]'?)|[^\]]+))*\])+"
+		. "|(\[(?P<attribute>[\w-]*)((?P<attribute_equals>[=~$|^*]+)"
+		. "(?P<attribute_value>(.+\[\]'?)|[^\]]+))*\])+"
 		. '|(?P<descendant>\s+)'
 		. '/';
 
-	const EQUALS_EXACT = "=";
-	const EQUALS_CONTAINS_WORD = "~=";
-	const EQUALS_ENDS_WITH = "$=";
-	const EQUALS_CONTAINS = "*=";
-	const EQUALS_OR_STARTS_WITH_HYPHENATED = "|=";
-	const EQUALS_STARTS_WITH = "^=";
+	public const EQUALS_EXACT = "=";
+	public const EQUALS_CONTAINS_WORD = "~=";
+	public const EQUALS_ENDS_WITH = "$=";
+	public const EQUALS_CONTAINS = "*=";
+	public const EQUALS_OR_STARTS_WITH_HYPHENATED = "|=";
+	public const EQUALS_STARTS_WITH = "^=";
+
+	private SingleSelectorConverter $singleSelectorConverter;
+	private SelectorListSplitter $selectorListSplitter;
 
 	public function __construct(
-			protected string $cssSelector,
-			protected string $prefix = ".//",
-			protected bool $htmlMode = true
-		) {
+		protected string $cssSelector,
+		protected string $prefix = ".//",
+		protected bool $htmlMode = true,
+		?SingleSelectorConverter $singleSelectorConverter = null,
+		?SelectorListSplitter $selectorListSplitter = null,
+	) {
+		$this->singleSelectorConverter = $singleSelectorConverter
+			?? new SingleSelectorConverter();
+		$this->selectorListSplitter = $selectorListSplitter
+			?? new SelectorListSplitter();
 	}
 
 	public function __toString():string {
-		return $this->asXPath();
+		return $this->convert($this->cssSelector);
 	}
 
+	// phpcs:disable Generic.NamingConventions.CamelCapsFunctionName
 	public function asXPath():string {
 		return $this->convert($this->cssSelector);
 	}
+	// phpcs:enable
 
 	protected function convert(string $css):string {
-		$cssArray = preg_split(
-			'/(["\']).*?\1(*SKIP)(*F)|,/',
-			$css
-		);
+		$cssArray = $this->selectorListSplitter->split($css);
 		$xPathArray = [];
 
 		foreach($cssArray as $input) {
-			$output = $this->convertSingleSelector(trim($input));
-			$xPathArray []= $output;
+			$xPathArray[] = $this->convertSingleSelector(trim($input));
 		}
 
 		return implode(" | ", $xPathArray);
 	}
 
 	protected function convertSingleSelector(string $css):string {
-		$thread = $this->preg_match_collated(self::cssRegex, $css);
-		$thread = array_values($thread);
-
-		$xpath = [$this->prefix];
-		$hasElement = false;
-		foreach($thread as $threadKey => $currentThreadItem) {
-			$next = isset($thread[$threadKey + 1])
-				? $thread[$threadKey + 1]
-				: false;
-
-			switch ($currentThreadItem["type"]) {
-			case "star":
-			case "element":
-				if($this->htmlMode) {
-					$xpath []= strtolower($currentThreadItem['content']);
-				} else {
-					$xpath []= $currentThreadItem['content'];
-				}
-				$hasElement = true;
-				break;
-
-			case "pseudo":
-				$specifier = "";
-				if ($next && $next["type"] == "pseudospecifier") {
-					$specifier = "{$next['content']}";
-				}
-
-				switch ($currentThreadItem["content"]) {
-				case "disabled":
-				case "checked":
-				case "selected":
-					array_push(
-						$xpath,
-						"[@{$currentThreadItem['content']}]"
-					);
-					break;
-
-				case "text":
-					array_push(
-						$xpath,
-						'[@type="text"]'
-					);
-					break;
-
-				case "contains":
-					if(empty($specifier)) {
-						continue 3;
-					}
-
-					array_push(
-						$xpath,
-						"[contains(text(),$specifier)]"
-					);
-					break;
-
-				case "first-child":
-					$prev = count($xpath) - 1;
-					$xpath[$prev] = '*[1]/self::' . $xpath[$prev];
-					break;
-
-				case "nth-child":
-					if (empty($specifier)) {
-						continue 3;
-					}
-
-					$prev = count($xpath) - 1;
-					$previous = $xpath[$prev];
-
-					if (substr($previous, -1, 1) === "]") {
-						$xpath[$prev] = str_replace(
-							"]",
-							" and position() = $specifier]",
-							$xpath[$prev]
-						);
-					}
-					else {
-						array_push(
-							$xpath,
-							"[$specifier]"
-						);
-					}
-					break;
-
-				case "last-child":
-					$prev = count($xpath) - 1;
-					$xpath[$prev] = '*[last()]/self::' . $xpath[$prev];
-					break;
-
-				case 'first-of-type':
-					$prev = count($xpath) - 1;
-					$previous = $xpath[$prev];
-
-					if(substr($previous, -1, 1) === "]") {
-						array_push(
-							$xpath,
-							"[1]"
-						);
-					}
-					else {
-						array_push(
-							$xpath,
-							"[1]"
-						);
-					}
-					break;
-
-				case "nth-of-type":
-					if (empty($specifier)) {
-						continue 3;
-					}
-
-					$prev = count($xpath) - 1;
-					$previous = $xpath[$prev];
-
-					if(substr($previous, -1, 1) === "]") {
-						array_push(
-							$xpath,
-							"[$specifier]"
-						);
-					}
-					else {
-						array_push(
-							$xpath,
-							"[$specifier]"
-						);
-					}
-					break;
-
-				case "last-of-type":
-					$prev = count($xpath) - 1;
-					$previous = $xpath[$prev];
-
-					if(substr($previous, -1, 1) === "]") {
-						array_push(
-							$xpath,
-							"[last()]"
-						);
-					}
-					else {
-						array_push(
-							$xpath,
-							"[last()]"
-						);
-					}
-					break;
-
-				}
-				break;
-
-			case "child":
-				array_push($xpath, "/");
-				$hasElement = false;
-				break;
-
-			case "id":
-				array_push(
-					$xpath,
-					($hasElement ? '' : '*')
-					. "[@id='{$currentThreadItem['content']}']"
-				);
-				$hasElement = true;
-				break;
-
-			case "class":
-				// https://devhints.io/xpath#class-check
-				array_push(
-					$xpath,
-					($hasElement ? '' : '*')
-					. "[contains(concat(' ',normalize-space(@class),' '),' {$currentThreadItem['content']} ')]"
-				);
-				$hasElement = true;
-				break;
-
-			case "sibling":
-				array_push(
-					$xpath,
-					"/following-sibling::*[1]/self::"
-				);
-				$hasElement = false;
-				break;
-
-			case "subsequentsibling":
-				array_push(
-					$xpath,
-					"/following-sibling::"
-				);
-				$hasElement = false;
-				break;
-
-			case "attribute":
-				if(!$hasElement) {
-					array_push($xpath, "*");
-					$hasElement = true;
-				}
-
-				if($this->htmlMode) {
-					$currentThreadItem['content'] = strtolower($currentThreadItem['content']);
-				}
-
-				/** @var null|array<int, array<string, string>> $detail */
-				$detail = $currentThreadItem["detail"] ?? null;
-				$detailType = $detail[0] ?? null;
-				$detailValue = $detail[1] ?? null;
-
-				if(!$detailType
-				|| $detailType["type"] !== "attribute_equals") {
-					array_push(
-						$xpath,
-						"[@{$currentThreadItem['content']}]"
-					);
-					continue 2;
-				}
-
-				$valueString = trim(
-					$detailValue["content"],
-					" '\""
-				);
-
-				$equalsType = $detailType["content"];
-				switch ($equalsType) {
-				case self::EQUALS_EXACT:
-					array_push(
-						$xpath,
-						"[@{$currentThreadItem['content']}=\"{$valueString}\"]"
-					);
-					break;
-
-				case self::EQUALS_CONTAINS:
-					array_push(
-						$xpath,
-						"[contains(@{$currentThreadItem['content']},\"{$valueString}\")]"
-					);
-					break;
-
-				case self::EQUALS_CONTAINS_WORD:
-					array_push(
-						$xpath,
-						"["
-						. "contains("
-						. "concat(\" \",@{$currentThreadItem['content']},\" \"),"
-						. "concat(\" \",\"{$valueString}\",\" \")"
-						. ")"
-						. "]"
-					);
-					break;
-
-				case self::EQUALS_OR_STARTS_WITH_HYPHENATED:
-					array_push(
-						$xpath,
-						"["
-						. "@{$currentThreadItem['content']}=\"{$valueString}\" or "
-						. "starts-with(@{$currentThreadItem['content']}, \"{$valueString}-\")"
-						. "]"
-					);
-					break;
-
-				case self::EQUALS_STARTS_WITH:
-					array_push(
-						$xpath,
-						"[starts-with("
-						. "@{$currentThreadItem['content']}, \"{$valueString}\""
-						. ")]"
-					);
-					break;
-
-				case self::EQUALS_ENDS_WITH:
-					array_push(
-						$xpath,
-						"["
-						. "substring("
-						. "@{$currentThreadItem['content']},"
-						. "string-length(@{$currentThreadItem['content']}) - "
-						. "string-length(\"{$valueString}\") + 1)"
-						. "=\"{$valueString}\""
-						. "]"
-					);
-					break;
-				}
-				break;
-
-			case "descendant":
-				array_push($xpath, "//");
-				$hasElement = false;
-				break;
-			}
-		}
-
-		return implode("", $xpath);
-	}
-
-	/** @return array<int, array<string, string>> */
-	protected function preg_match_collated(
-		string $regex,
-		string $string,
-		?callable $transform = null
-	):array {
-		preg_match_all(
-			$regex,
-			$string,
-			$matches,
-			PREG_PATTERN_ORDER
+		return $this->singleSelectorConverter->convert(
+			$css,
+			$this->prefix,
+			$this->htmlMode
 		);
-
-		$set = [];
-		foreach($matches[0] as $k => $v) {
-			if(!empty($v)) {
-				$set[$k] = null;
-			}
-		}
-
-		foreach($matches as $k => $m) {
-			if(is_numeric($k)) {
-				continue;
-			}
-
-			foreach($m as $i => $match) {
-				if($match === "") {
-					continue;
-				}
-
-				$toSet = null;
-
-				if($transform) {
-					$toSet = $transform($k, $match);
-				}
-				else {
-					$toSet = ["type" => $k, "content" => $match];
-				}
-
-				if(!isset($set[$i])) {
-					$set[$i] = $toSet;
-				}
-				else {
-					if(!isset($set[$i]["detail"])) {
-						$set[$i]["detail"] = [];
-					}
-
-					array_push($set[$i]["detail"], $toSet);
-				}
-			}
-		}
-
-		return $set;
 	}
 }

+ 54 - 0
lib/phpgt/cssxpath/src/XPathExpression.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace Gt\CssXPath;
+
+class XPathExpression {
+	/** @var array<int, string> */
+	private array $parts;
+	private bool $hasElement = false;
+
+	public function __construct(string $prefix) {
+		$this->parts = [$prefix];
+	}
+
+	public function appendElement(string $element, bool $htmlMode):void {
+		$this->parts[] = $htmlMode ? strtolower($element) : $element;
+		$this->hasElement = true;
+	}
+
+	public function ensureElement():void {
+		if($this->hasElement) {
+			return;
+		}
+
+		$this->parts[] = "*";
+		$this->hasElement = true;
+	}
+
+	public function appendFragment(string $fragment):void {
+		$this->parts[] = $fragment;
+	}
+
+	public function markElementMissing():void {
+		$this->hasElement = false;
+	}
+
+	public function prependToLast(string $prefix):void {
+		$index = count($this->parts) - 1;
+		$this->parts[$index] = $prefix . $this->parts[$index];
+	}
+
+	public function replaceInLast(string $search, string $replace):void {
+		$index = count($this->parts) - 1;
+		$this->parts[$index] = str_replace($search, $replace, $this->parts[$index]);
+	}
+
+	public function lastPartEndsWith(string $suffix):bool {
+		$index = count($this->parts) - 1;
+		return substr($this->parts[$index], -strlen($suffix)) === $suffix;
+	}
+
+	public function toString():string {
+		return implode("", $this->parts);
+	}
+}