add server-side code and mobile app code

This commit is contained in:
Odweta
2024-01-07 01:11:37 +00:00
parent 5aff4ac5a6
commit 263efd5b65
402 changed files with 71488 additions and 0 deletions
+692
View File
@@ -0,0 +1,692 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit;
use Symfony\Component\BrowserKit\Exception\BadMethodCallException;
use Symfony\Component\BrowserKit\Exception\InvalidArgumentException;
use Symfony\Component\BrowserKit\Exception\LogicException;
use Symfony\Component\BrowserKit\Exception\RuntimeException;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\Form;
use Symfony\Component\DomCrawler\Link;
use Symfony\Component\Process\PhpProcess;
/**
* Simulates a browser.
*
* To make the actual request, you need to implement the doRequest() method.
*
* If you want to be able to run requests in their own process (insulated flag),
* you need to also implement the getScript() method.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
abstract class AbstractBrowser
{
protected $history;
protected $cookieJar;
protected $server = [];
protected $internalRequest;
protected $request;
protected $internalResponse;
protected $response;
protected $crawler;
protected bool $useHtml5Parser = true;
protected $insulated = false;
protected $redirect;
protected $followRedirects = true;
protected $followMetaRefresh = false;
private int $maxRedirects = -1;
private int $redirectCount = 0;
private array $redirects = [];
private bool $isMainRequest = true;
/**
* @param array $server The server parameters (equivalent of $_SERVER)
*/
public function __construct(array $server = [], History $history = null, CookieJar $cookieJar = null)
{
$this->setServerParameters($server);
$this->history = $history ?? new History();
$this->cookieJar = $cookieJar ?? new CookieJar();
}
/**
* Sets whether to automatically follow redirects or not.
*
* @return void
*/
public function followRedirects(bool $followRedirects = true)
{
$this->followRedirects = $followRedirects;
}
/**
* Sets whether to automatically follow meta refresh redirects or not.
*
* @return void
*/
public function followMetaRefresh(bool $followMetaRefresh = true)
{
$this->followMetaRefresh = $followMetaRefresh;
}
/**
* Returns whether client automatically follows redirects or not.
*/
public function isFollowingRedirects(): bool
{
return $this->followRedirects;
}
/**
* Sets the maximum number of redirects that crawler can follow.
*
* @return void
*/
public function setMaxRedirects(int $maxRedirects)
{
$this->maxRedirects = $maxRedirects < 0 ? -1 : $maxRedirects;
$this->followRedirects = -1 !== $this->maxRedirects;
}
/**
* Returns the maximum number of redirects that crawler can follow.
*/
public function getMaxRedirects(): int
{
return $this->maxRedirects;
}
/**
* Sets the insulated flag.
*
* @return void
*
* @throws LogicException When Symfony Process Component is not installed
*/
public function insulate(bool $insulated = true)
{
if ($insulated && !class_exists(\Symfony\Component\Process\Process::class)) {
throw new LogicException('Unable to isolate requests as the Symfony Process Component is not installed. Try running "composer require symfony/process".');
}
$this->insulated = $insulated;
}
/**
* Sets server parameters.
*
* @return void
*/
public function setServerParameters(array $server)
{
$this->server = array_merge([
'HTTP_USER_AGENT' => 'Symfony BrowserKit',
], $server);
}
/**
* Sets single server parameter.
*
* @return void
*/
public function setServerParameter(string $key, string $value)
{
$this->server[$key] = $value;
}
/**
* Gets single server parameter for specified key.
*/
public function getServerParameter(string $key, mixed $default = ''): mixed
{
return $this->server[$key] ?? $default;
}
public function xmlHttpRequest(string $method, string $uri, array $parameters = [], array $files = [], array $server = [], string $content = null, bool $changeHistory = true): Crawler
{
$this->setServerParameter('HTTP_X_REQUESTED_WITH', 'XMLHttpRequest');
try {
return $this->request($method, $uri, $parameters, $files, $server, $content, $changeHistory);
} finally {
unset($this->server['HTTP_X_REQUESTED_WITH']);
}
}
/**
* Converts the request parameters into a JSON string and uses it as request content.
*/
public function jsonRequest(string $method, string $uri, array $parameters = [], array $server = [], bool $changeHistory = true): Crawler
{
$content = json_encode($parameters);
$this->setServerParameter('CONTENT_TYPE', 'application/json');
$this->setServerParameter('HTTP_ACCEPT', 'application/json');
try {
return $this->request($method, $uri, [], [], $server, $content, $changeHistory);
} finally {
unset($this->server['CONTENT_TYPE']);
unset($this->server['HTTP_ACCEPT']);
}
}
/**
* Returns the History instance.
*/
public function getHistory(): History
{
return $this->history;
}
/**
* Returns the CookieJar instance.
*/
public function getCookieJar(): CookieJar
{
return $this->cookieJar;
}
/**
* Returns the current Crawler instance.
*/
public function getCrawler(): Crawler
{
return $this->crawler ?? throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".', __METHOD__));
}
/**
* Sets whether parsing should be done using "masterminds/html5".
*
* @return $this
*/
public function useHtml5Parser(bool $useHtml5Parser): static
{
$this->useHtml5Parser = $useHtml5Parser;
return $this;
}
/**
* Returns the current BrowserKit Response instance.
*/
public function getInternalResponse(): Response
{
return $this->internalResponse ?? throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".', __METHOD__));
}
/**
* Returns the current origin response instance.
*
* The origin response is the response instance that is returned
* by the code that handles requests.
*
* @see doRequest()
*/
public function getResponse(): object
{
return $this->response ?? throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".', __METHOD__));
}
/**
* Returns the current BrowserKit Request instance.
*/
public function getInternalRequest(): Request
{
return $this->internalRequest ?? throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".', __METHOD__));
}
/**
* Returns the current origin Request instance.
*
* The origin request is the request instance that is sent
* to the code that handles requests.
*
* @see doRequest()
*/
public function getRequest(): object
{
return $this->request ?? throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".', __METHOD__));
}
/**
* Clicks on a given link.
*
* @param array $serverParameters An array of server parameters
*/
public function click(Link $link/* , array $serverParameters = [] */): Crawler
{
$serverParameters = 1 < \func_num_args() ? func_get_arg(1) : [];
if ($link instanceof Form) {
return $this->submit($link, [], $serverParameters);
}
return $this->request($link->getMethod(), $link->getUri(), [], [], $serverParameters);
}
/**
* Clicks the first link (or clickable image) that contains the given text.
*
* @param string $linkText The text of the link or the alt attribute of the clickable image
* @param array $serverParameters An array of server parameters
*/
public function clickLink(string $linkText/* , array $serverParameters = [] */): Crawler
{
$serverParameters = 1 < \func_num_args() ? func_get_arg(1) : [];
$crawler = $this->crawler ?? throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".', __METHOD__));
return $this->click($crawler->selectLink($linkText)->link(), $serverParameters);
}
/**
* Submits a form.
*
* @param array $values An array of form field values
* @param array $serverParameters An array of server parameters
*/
public function submit(Form $form, array $values = [], array $serverParameters = []): Crawler
{
$form->setValues($values);
return $this->request($form->getMethod(), $form->getUri(), $form->getPhpValues(), $form->getPhpFiles(), $serverParameters);
}
/**
* Finds the first form that contains a button with the given content and
* uses it to submit the given form field values.
*
* @param string $button The text content, id, value or name of the form <button> or <input type="submit">
* @param array $fieldValues Use this syntax: ['my_form[name]' => '...', 'my_form[email]' => '...']
* @param string $method The HTTP method used to submit the form
* @param array $serverParameters These values override the ones stored in $_SERVER (HTTP headers must include an HTTP_ prefix as PHP does)
*/
public function submitForm(string $button, array $fieldValues = [], string $method = 'POST', array $serverParameters = []): Crawler
{
$crawler = $this->crawler ?? throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".', __METHOD__));
$buttonNode = $crawler->selectButton($button);
if (0 === $buttonNode->count()) {
throw new InvalidArgumentException(sprintf('There is no button with "%s" as its content, id, value or name.', $button));
}
$form = $buttonNode->form($fieldValues, $method);
return $this->submit($form, [], $serverParameters);
}
/**
* Calls a URI.
*
* @param string $method The request method
* @param string $uri The URI to fetch
* @param array $parameters The Request parameters
* @param array $files The files
* @param array $server The server parameters (HTTP headers are referenced with an HTTP_ prefix as PHP does)
* @param string $content The raw body data
* @param bool $changeHistory Whether to update the history or not (only used internally for back(), forward(), and reload())
*/
public function request(string $method, string $uri, array $parameters = [], array $files = [], array $server = [], string $content = null, bool $changeHistory = true): Crawler
{
if ($this->isMainRequest) {
$this->redirectCount = 0;
} else {
++$this->redirectCount;
}
$originalUri = $uri;
$uri = $this->getAbsoluteUri($uri);
$server = array_merge($this->server, $server);
if (!empty($server['HTTP_HOST']) && null === parse_url($originalUri, \PHP_URL_HOST)) {
$uri = preg_replace('{^(https?\://)'.preg_quote($this->extractHost($uri)).'}', '${1}'.$server['HTTP_HOST'], $uri);
}
if (isset($server['HTTPS']) && null === parse_url($originalUri, \PHP_URL_SCHEME)) {
$uri = preg_replace('{^'.parse_url($uri, \PHP_URL_SCHEME).'}', $server['HTTPS'] ? 'https' : 'http', $uri);
}
if (!isset($server['HTTP_REFERER']) && !$this->history->isEmpty()) {
$server['HTTP_REFERER'] = $this->history->current()->getUri();
}
if (empty($server['HTTP_HOST'])) {
$server['HTTP_HOST'] = $this->extractHost($uri);
}
$server['HTTPS'] = 'https' === parse_url($uri, \PHP_URL_SCHEME);
$this->internalRequest = new Request($uri, $method, $parameters, $files, $this->cookieJar->allValues($uri), $server, $content);
$this->request = $this->filterRequest($this->internalRequest);
if (true === $changeHistory) {
$this->history->add($this->internalRequest);
}
if ($this->insulated) {
$this->response = $this->doRequestInProcess($this->request);
} else {
$this->response = $this->doRequest($this->request);
}
$this->internalResponse = $this->filterResponse($this->response);
$this->cookieJar->updateFromResponse($this->internalResponse, $uri);
$status = $this->internalResponse->getStatusCode();
if ($status >= 300 && $status < 400) {
$this->redirect = $this->internalResponse->getHeader('Location');
} else {
$this->redirect = null;
}
if ($this->followRedirects && $this->redirect) {
$this->redirects[serialize($this->history->current())] = true;
return $this->crawler = $this->followRedirect();
}
$this->crawler = $this->createCrawlerFromContent($this->internalRequest->getUri(), $this->internalResponse->getContent(), $this->internalResponse->getHeader('Content-Type') ?? '');
// Check for meta refresh redirect
if ($this->followMetaRefresh && null !== $redirect = $this->getMetaRefreshUrl()) {
$this->redirect = $redirect;
$this->redirects[serialize($this->history->current())] = true;
$this->crawler = $this->followRedirect();
}
return $this->crawler;
}
/**
* Makes a request in another process.
*
* @return object
*
* @throws \RuntimeException When processing returns exit code
*/
protected function doRequestInProcess(object $request)
{
$deprecationsFile = tempnam(sys_get_temp_dir(), 'deprec');
putenv('SYMFONY_DEPRECATIONS_SERIALIZE='.$deprecationsFile);
$_ENV['SYMFONY_DEPRECATIONS_SERIALIZE'] = $deprecationsFile;
$process = new PhpProcess($this->getScript($request), null, null);
$process->run();
if (file_exists($deprecationsFile)) {
$deprecations = file_get_contents($deprecationsFile);
unlink($deprecationsFile);
foreach ($deprecations ? unserialize($deprecations) : [] as $deprecation) {
if ($deprecation[0]) {
// unsilenced on purpose
trigger_error($deprecation[1], \E_USER_DEPRECATED);
} else {
@trigger_error($deprecation[1], \E_USER_DEPRECATED);
}
}
}
if (!$process->isSuccessful() || !preg_match('/^O\:\d+\:/', $process->getOutput())) {
throw new RuntimeException(sprintf('OUTPUT: %s ERROR OUTPUT: %s.', $process->getOutput(), $process->getErrorOutput()));
}
return unserialize($process->getOutput());
}
/**
* Makes a request.
*
* @return object
*/
abstract protected function doRequest(object $request);
/**
* Returns the script to execute when the request must be insulated.
*
* @param object $request An origin request instance
*
* @return string
*
* @throws LogicException When this abstract class is not implemented
*/
protected function getScript(object $request)
{
throw new LogicException('To insulate requests, you need to override the getScript() method.');
}
/**
* Filters the BrowserKit request to the origin one.
*
* @return object
*/
protected function filterRequest(Request $request)
{
return $request;
}
/**
* Filters the origin response to the BrowserKit one.
*
* @return Response
*/
protected function filterResponse(object $response)
{
return $response;
}
/**
* Creates a crawler.
*
* This method returns null if the DomCrawler component is not available.
*/
protected function createCrawlerFromContent(string $uri, string $content, string $type): ?Crawler
{
if (!class_exists(Crawler::class)) {
return null;
}
$crawler = new Crawler(null, $uri, null, $this->useHtml5Parser);
$crawler->addContent($content, $type);
return $crawler;
}
/**
* Goes back in the browser history.
*/
public function back(): Crawler
{
do {
$request = $this->history->back();
} while (\array_key_exists(serialize($request), $this->redirects));
return $this->requestFromRequest($request, false);
}
/**
* Goes forward in the browser history.
*/
public function forward(): Crawler
{
do {
$request = $this->history->forward();
} while (\array_key_exists(serialize($request), $this->redirects));
return $this->requestFromRequest($request, false);
}
/**
* Reloads the current browser.
*/
public function reload(): Crawler
{
return $this->requestFromRequest($this->history->current(), false);
}
/**
* Follow redirects?
*
* @throws LogicException If request was not a redirect
*/
public function followRedirect(): Crawler
{
if (empty($this->redirect)) {
throw new LogicException('The request was not redirected.');
}
if (-1 !== $this->maxRedirects) {
if ($this->redirectCount > $this->maxRedirects) {
$this->redirectCount = 0;
throw new LogicException(sprintf('The maximum number (%d) of redirections was reached.', $this->maxRedirects));
}
}
$request = $this->internalRequest;
if (\in_array($this->internalResponse->getStatusCode(), [301, 302, 303])) {
$method = 'GET';
$files = [];
$content = null;
} else {
$method = $request->getMethod();
$files = $request->getFiles();
$content = $request->getContent();
}
if ('GET' === strtoupper($method)) {
// Don't forward parameters for GET request as it should reach the redirection URI
$parameters = [];
} else {
$parameters = $request->getParameters();
}
$server = $request->getServer();
$server = $this->updateServerFromUri($server, $this->redirect);
$this->isMainRequest = false;
$response = $this->request($method, $this->redirect, $parameters, $files, $server, $content);
$this->isMainRequest = true;
return $response;
}
/**
* @see https://dev.w3.org/html5/spec-preview/the-meta-element.html#attr-meta-http-equiv-refresh
*/
private function getMetaRefreshUrl(): ?string
{
$metaRefresh = $this->getCrawler()->filter('head meta[http-equiv="refresh"]');
foreach ($metaRefresh->extract(['content']) as $content) {
if (preg_match('/^\s*0\s*;\s*URL\s*=\s*(?|\'([^\']++)|"([^"]++)|([^\'"].*))/i', $content, $m)) {
return str_replace("\t\r\n", '', rtrim($m[1]));
}
}
return null;
}
/**
* Restarts the client.
*
* It flushes history and all cookies.
*
* @return void
*/
public function restart()
{
$this->cookieJar->clear();
$this->history->clear();
}
/**
* Takes a URI and converts it to absolute if it is not already absolute.
*/
protected function getAbsoluteUri(string $uri): string
{
// already absolute?
if (str_starts_with($uri, 'http://') || str_starts_with($uri, 'https://')) {
return $uri;
}
if (!$this->history->isEmpty()) {
$currentUri = $this->history->current()->getUri();
} else {
$currentUri = sprintf('http%s://%s/',
isset($this->server['HTTPS']) ? 's' : '',
$this->server['HTTP_HOST'] ?? 'localhost'
);
}
// protocol relative URL
if ('' !== trim($uri, '/') && str_starts_with($uri, '//')) {
return parse_url($currentUri, \PHP_URL_SCHEME).':'.$uri;
}
// anchor or query string parameters?
if (!$uri || '#' === $uri[0] || '?' === $uri[0]) {
return preg_replace('/[#?].*?$/', '', $currentUri).$uri;
}
if ('/' !== $uri[0]) {
$path = parse_url($currentUri, \PHP_URL_PATH);
if (!str_ends_with($path, '/')) {
$path = substr($path, 0, strrpos($path, '/') + 1);
}
$uri = $path.$uri;
}
return preg_replace('#^(.*?//[^/]+)\/.*$#', '$1', $currentUri).$uri;
}
/**
* Makes a request from a Request object directly.
*
* @param bool $changeHistory Whether to update the history or not (only used internally for back(), forward(), and reload())
*/
protected function requestFromRequest(Request $request, bool $changeHistory = true): Crawler
{
return $this->request($request->getMethod(), $request->getUri(), $request->getParameters(), $request->getFiles(), $request->getServer(), $request->getContent(), $changeHistory);
}
private function updateServerFromUri(array $server, string $uri): array
{
$server['HTTP_HOST'] = $this->extractHost($uri);
$scheme = parse_url($uri, \PHP_URL_SCHEME);
$server['HTTPS'] = null === $scheme ? $server['HTTPS'] : 'https' === $scheme;
unset($server['HTTP_IF_NONE_MATCH'], $server['HTTP_IF_MODIFIED_SINCE']);
return $server;
}
private function extractHost(string $uri): ?string
{
$host = parse_url($uri, \PHP_URL_HOST);
if ($port = parse_url($uri, \PHP_URL_PORT)) {
return $host.':'.$port;
}
return $host;
}
}
+78
View File
@@ -0,0 +1,78 @@
CHANGELOG
=========
6.4
---
* Add argument `$serverParameters` to `AbstractBrowser::click()` and `AbstractBrowser::clickLink()`
6.3
---
* Add `AbstractBrowser::useHtml5Parser()`
6.1
---
* Add `toArray` method to `Response`
5.3
---
* Added `jsonRequest` method to `AbstractBrowser`
* Allowed sending a body with GET requests when a content-type is defined
5.2.0
-----
* [BC BREAK] Request parameters are now casted to string in `Request::__construct()`.
4.3.0
-----
* Added PHPUnit constraints: `BrowserCookieValueSame` and `BrowserHasCookie`
* Added `HttpBrowser`, an implementation of a browser with the HttpClient component
* Renamed `Client` to `AbstractBrowser`
* Marked `Response` final.
* Deprecated `Response::buildHeader()`
* Deprecated `Response::getStatus()`, use `Response::getStatusCode()` instead
4.2.0
-----
* The method `Client::submit()` will have a new `$serverParameters` argument
in version 5.0, not defining it is deprecated
* Added ability to read the "samesite" attribute of cookies using `Cookie::getSameSite()`
3.4.0
-----
* [BC BREAK] Client will skip redirects during history navigation
(back and forward calls) according to W3C Browsers recommendation
3.3.0
-----
* [BC BREAK] The request method is dropped from POST to GET when the response
status code is 301.
3.2.0
-----
* Client HTTP user agent has been changed to 'Symfony BrowserKit'
2.3.0
-----
* [BC BREAK] `Client::followRedirect()` won't redirect responses with
a non-3xx Status Code and `Location` header anymore, as per
http://tools.ietf.org/html/rfc2616#section-14.30
* added `Client::getInternalRequest()` and `Client::getInternalResponse()` to
have access to the BrowserKit internal request and response objects
2.1.0
-----
* [BC BREAK] The CookieJar internals have changed to allow cookies with the
same name on different sub-domains/sub-paths
+300
View File
@@ -0,0 +1,300 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit;
use Symfony\Component\BrowserKit\Exception\InvalidArgumentException;
use Symfony\Component\BrowserKit\Exception\UnexpectedValueException;
/**
* Cookie represents an HTTP cookie.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class Cookie
{
/**
* Handles dates as defined by RFC 2616 section 3.3.1, and also some other
* non-standard, but common formats.
*/
private const DATE_FORMATS = [
'D, d M Y H:i:s T',
'D, d-M-y H:i:s T',
'D, d-M-Y H:i:s T',
'D, d-m-y H:i:s T',
'D, d-m-Y H:i:s T',
'D M j G:i:s Y',
'D M d H:i:s Y T',
];
protected $name;
protected $value;
protected $expires;
protected $path;
protected $domain;
protected $secure;
protected $httponly;
protected $rawValue;
private ?string $samesite;
/**
* Sets a cookie.
*
* @param string $name The cookie name
* @param string|null $value The value of the cookie
* @param string|null $expires The time the cookie expires
* @param string|null $path The path on the server in which the cookie will be available on
* @param string $domain The domain that the cookie is available
* @param bool $secure Indicates that the cookie should only be transmitted over a secure HTTPS connection from the client
* @param bool $httponly The cookie httponly flag
* @param bool $encodedValue Whether the value is encoded or not
* @param string|null $samesite The cookie samesite attribute
*/
public function __construct(string $name, ?string $value, string $expires = null, string $path = null, string $domain = '', bool $secure = false, bool $httponly = true, bool $encodedValue = false, string $samesite = null)
{
if ($encodedValue) {
$this->rawValue = $value ?? '';
$this->value = urldecode($this->rawValue);
} else {
$this->value = $value ?? '';
$this->rawValue = rawurlencode($this->value);
}
$this->name = $name;
$this->path = empty($path) ? '/' : $path;
$this->domain = $domain;
$this->secure = $secure;
$this->httponly = $httponly;
$this->samesite = $samesite;
if (null !== $expires) {
$timestampAsDateTime = \DateTimeImmutable::createFromFormat('U', $expires);
if (false === $timestampAsDateTime) {
throw new UnexpectedValueException(sprintf('The cookie expiration time "%s" is not valid.', $expires));
}
$this->expires = $timestampAsDateTime->format('U');
}
}
/**
* Returns the HTTP representation of the Cookie.
*/
public function __toString(): string
{
$cookie = sprintf('%s=%s', $this->name, $this->rawValue);
if (null !== $this->expires) {
$dateTime = \DateTimeImmutable::createFromFormat('U', $this->expires, new \DateTimeZone('GMT'));
$cookie .= '; expires='.str_replace('+0000', '', $dateTime->format(self::DATE_FORMATS[0]));
}
if ('' !== $this->domain) {
$cookie .= '; domain='.$this->domain;
}
if ($this->path) {
$cookie .= '; path='.$this->path;
}
if ($this->secure) {
$cookie .= '; secure';
}
if ($this->httponly) {
$cookie .= '; httponly';
}
if (null !== $this->samesite) {
$cookie .= '; samesite='.$this->samesite;
}
return $cookie;
}
/**
* Creates a Cookie instance from a Set-Cookie header value.
*
* @throws InvalidArgumentException
*/
public static function fromString(string $cookie, string $url = null): static
{
$parts = explode(';', $cookie);
if (!str_contains($parts[0], '=')) {
throw new InvalidArgumentException(sprintf('The cookie string "%s" is not valid.', $parts[0]));
}
[$name, $value] = explode('=', array_shift($parts), 2);
$values = [
'name' => trim($name),
'value' => trim($value),
'expires' => null,
'path' => '/',
'domain' => '',
'secure' => false,
'httponly' => false,
'passedRawValue' => true,
'samesite' => null,
];
if (null !== $url) {
if ((false === $urlParts = parse_url($url)) || !isset($urlParts['host'])) {
throw new InvalidArgumentException(sprintf('The URL "%s" is not valid.', $url));
}
$values['domain'] = $urlParts['host'];
$values['path'] = isset($urlParts['path']) ? substr($urlParts['path'], 0, strrpos($urlParts['path'], '/')) : '';
}
foreach ($parts as $part) {
$part = trim($part);
if ('secure' === strtolower($part)) {
// Ignore the secure flag if the original URI is not given or is not HTTPS
if (!$url || !isset($urlParts['scheme']) || 'https' !== $urlParts['scheme']) {
continue;
}
$values['secure'] = true;
continue;
}
if ('httponly' === strtolower($part)) {
$values['httponly'] = true;
continue;
}
if (2 === \count($elements = explode('=', $part, 2))) {
if ('expires' === strtolower($elements[0])) {
$elements[1] = self::parseDate($elements[1]);
}
$values[strtolower($elements[0])] = $elements[1];
}
}
return new static(
$values['name'],
$values['value'],
$values['expires'],
$values['path'],
$values['domain'],
$values['secure'],
$values['httponly'],
$values['passedRawValue'],
$values['samesite']
);
}
private static function parseDate(string $dateValue): ?string
{
// trim single quotes around date if present
if (($length = \strlen($dateValue)) > 1 && "'" === $dateValue[0] && "'" === $dateValue[$length - 1]) {
$dateValue = substr($dateValue, 1, -1);
}
foreach (self::DATE_FORMATS as $dateFormat) {
if (false !== $date = \DateTimeImmutable::createFromFormat($dateFormat, $dateValue, new \DateTimeZone('GMT'))) {
return $date->format('U');
}
}
// attempt a fallback for unusual formatting
if (false !== $date = date_create_immutable($dateValue, new \DateTimeZone('GMT'))) {
return $date->format('U');
}
return null;
}
/**
* Gets the name of the cookie.
*/
public function getName(): string
{
return $this->name;
}
/**
* Gets the value of the cookie.
*/
public function getValue(): string
{
return $this->value;
}
/**
* Gets the raw value of the cookie.
*/
public function getRawValue(): string
{
return $this->rawValue;
}
/**
* Gets the expires time of the cookie.
*/
public function getExpiresTime(): ?string
{
return $this->expires;
}
/**
* Gets the path of the cookie.
*/
public function getPath(): string
{
return $this->path;
}
/**
* Gets the domain of the cookie.
*/
public function getDomain(): string
{
return $this->domain;
}
/**
* Returns the secure flag of the cookie.
*/
public function isSecure(): bool
{
return $this->secure;
}
/**
* Returns the httponly flag of the cookie.
*/
public function isHttpOnly(): bool
{
return $this->httponly;
}
/**
* Returns true if the cookie has expired.
*/
public function isExpired(): bool
{
return null !== $this->expires && 0 != $this->expires && $this->expires <= time();
}
/**
* Gets the samesite attribute of the cookie.
*/
public function getSameSite(): ?string
{
return $this->samesite;
}
}
+231
View File
@@ -0,0 +1,231 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit;
use Symfony\Component\BrowserKit\Exception\InvalidArgumentException;
/**
* CookieJar.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class CookieJar
{
protected $cookieJar = [];
/**
* @return void
*/
public function set(Cookie $cookie)
{
$this->cookieJar[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie;
}
/**
* Gets a cookie by name.
*
* You should never use an empty domain, but if you do so,
* this method returns the first cookie for the given name/path
* (this behavior ensures a BC behavior with previous versions of
* Symfony).
*/
public function get(string $name, string $path = '/', string $domain = null): ?Cookie
{
$this->flushExpiredCookies();
foreach ($this->cookieJar as $cookieDomain => $pathCookies) {
if ($cookieDomain && $domain) {
$cookieDomain = '.'.ltrim($cookieDomain, '.');
if (!str_ends_with('.'.$domain, $cookieDomain)) {
continue;
}
}
foreach ($pathCookies as $cookiePath => $namedCookies) {
if (!str_starts_with($path, $cookiePath)) {
continue;
}
if (isset($namedCookies[$name])) {
return $namedCookies[$name];
}
}
}
return null;
}
/**
* Removes a cookie by name.
*
* You should never use an empty domain, but if you do so,
* all cookies for the given name/path expire (this behavior
* ensures a BC behavior with previous versions of Symfony).
*
* @return void
*/
public function expire(string $name, ?string $path = '/', string $domain = null)
{
$path ??= '/';
if (empty($domain)) {
// an empty domain means any domain
// this should never happen but it allows for a better BC
$domains = array_keys($this->cookieJar);
} else {
$domains = [$domain];
}
foreach ($domains as $domain) {
unset($this->cookieJar[$domain][$path][$name]);
if (empty($this->cookieJar[$domain][$path])) {
unset($this->cookieJar[$domain][$path]);
if (empty($this->cookieJar[$domain])) {
unset($this->cookieJar[$domain]);
}
}
}
}
/**
* Removes all the cookies from the jar.
*
* @return void
*/
public function clear()
{
$this->cookieJar = [];
}
/**
* Updates the cookie jar from a response Set-Cookie headers.
*
* @param string[] $setCookies Set-Cookie headers from an HTTP response
*
* @return void
*/
public function updateFromSetCookie(array $setCookies, string $uri = null)
{
$cookies = [];
foreach ($setCookies as $cookie) {
foreach (explode(',', $cookie) as $i => $part) {
if (0 === $i || preg_match('/^(?P<token>\s*[0-9A-Za-z!#\$%\&\'\*\+\-\.^_`\|~]+)=/', $part)) {
$cookies[] = ltrim($part);
} else {
$cookies[\count($cookies) - 1] .= ','.$part;
}
}
}
foreach ($cookies as $cookie) {
try {
$this->set(Cookie::fromString($cookie, $uri));
} catch (InvalidArgumentException) {
// invalid cookies are just ignored
}
}
}
/**
* Updates the cookie jar from a Response object.
*
* @return void
*/
public function updateFromResponse(Response $response, string $uri = null)
{
$this->updateFromSetCookie($response->getHeader('Set-Cookie', false), $uri);
}
/**
* Returns not yet expired cookies.
*
* @return Cookie[]
*/
public function all(): array
{
$this->flushExpiredCookies();
$flattenedCookies = [];
foreach ($this->cookieJar as $path) {
foreach ($path as $cookies) {
foreach ($cookies as $cookie) {
$flattenedCookies[] = $cookie;
}
}
}
return $flattenedCookies;
}
/**
* Returns not yet expired cookie values for the given URI.
*/
public function allValues(string $uri, bool $returnsRawValue = false): array
{
$this->flushExpiredCookies();
$parts = array_replace(['path' => '/'], parse_url($uri));
$cookies = [];
foreach ($this->cookieJar as $domain => $pathCookies) {
if ($domain) {
$domain = '.'.ltrim($domain, '.');
if (!str_ends_with('.'.$parts['host'], $domain)) {
continue;
}
}
foreach ($pathCookies as $path => $namedCookies) {
if (!str_starts_with($parts['path'], $path)) {
continue;
}
foreach ($namedCookies as $cookie) {
if ($cookie->isSecure() && 'https' !== $parts['scheme']) {
continue;
}
$cookies[$cookie->getName()] = $returnsRawValue ? $cookie->getRawValue() : $cookie->getValue();
}
}
}
return $cookies;
}
/**
* Returns not yet expired raw cookie values for the given URI.
*/
public function allRawValues(string $uri): array
{
return $this->allValues($uri, true);
}
/**
* Removes all expired cookies.
*
* @return void
*/
public function flushExpiredCookies()
{
foreach ($this->cookieJar as $domain => $pathCookies) {
foreach ($pathCookies as $path => $namedCookies) {
foreach ($namedCookies as $name => $cookie) {
if ($cookie->isExpired()) {
unset($this->cookieJar[$domain][$path][$name]);
}
}
}
}
}
}
@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Exception;
class BadMethodCallException extends \BadMethodCallException implements ExceptionInterface
{
}
@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Exception;
/**
* Base ExceptionInterface for the BrowserKit component.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
interface ExceptionInterface extends \Throwable
{
}
@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Exception;
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
}
@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Exception;
class JsonException extends \JsonException implements ExceptionInterface
{
}
@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Exception;
class LogicException extends \LogicException implements ExceptionInterface
{
}
@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Exception;
class RuntimeException extends \RuntimeException implements ExceptionInterface
{
}
@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Exception;
class UnexpectedValueException extends \UnexpectedValueException implements ExceptionInterface
{
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit;
use Symfony\Component\BrowserKit\Exception\LogicException;
/**
* History.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class History
{
protected $stack = [];
protected $position = -1;
/**
* Clears the history.
*
* @return void
*/
public function clear()
{
$this->stack = [];
$this->position = -1;
}
/**
* Adds a Request to the history.
*
* @return void
*/
public function add(Request $request)
{
$this->stack = \array_slice($this->stack, 0, $this->position + 1);
$this->stack[] = clone $request;
$this->position = \count($this->stack) - 1;
}
/**
* Returns true if the history is empty.
*/
public function isEmpty(): bool
{
return 0 === \count($this->stack);
}
/**
* Goes back in the history.
*
* @throws LogicException if the stack is already on the first page
*/
public function back(): Request
{
if ($this->position < 1) {
throw new LogicException('You are already on the first page.');
}
return clone $this->stack[--$this->position];
}
/**
* Goes forward in the history.
*
* @throws LogicException if the stack is already on the last page
*/
public function forward(): Request
{
if ($this->position > \count($this->stack) - 2) {
throw new LogicException('You are already on the last page.');
}
return clone $this->stack[++$this->position];
}
/**
* Returns the current element in the history.
*
* @throws LogicException if the stack is empty
*/
public function current(): Request
{
if (-1 === $this->position) {
throw new LogicException('The page history is empty.');
}
return clone $this->stack[$this->position];
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit;
use Symfony\Component\BrowserKit\Exception\LogicException;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Component\Mime\Part\TextPart;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* An implementation of a browser using the HttpClient component
* to make real HTTP requests.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class HttpBrowser extends AbstractBrowser
{
private HttpClientInterface $client;
public function __construct(HttpClientInterface $client = null, History $history = null, CookieJar $cookieJar = null)
{
if (!$client && !class_exists(HttpClient::class)) {
throw new LogicException(sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__));
}
$this->client = $client ?? HttpClient::create();
parent::__construct([], $history, $cookieJar);
}
/**
* @param Request $request
*/
protected function doRequest(object $request): Response
{
$headers = $this->getHeaders($request);
[$body, $extraHeaders] = $this->getBodyAndExtraHeaders($request, $headers);
$response = $this->client->request($request->getMethod(), $request->getUri(), [
'headers' => array_merge($headers, $extraHeaders),
'body' => $body,
'max_redirects' => 0,
]);
return new Response($response->getContent(false), $response->getStatusCode(), $response->getHeaders(false));
}
/**
* @return array [$body, $headers]
*/
private function getBodyAndExtraHeaders(Request $request, array $headers): array
{
if (\in_array($request->getMethod(), ['GET', 'HEAD']) && !isset($headers['content-type'])) {
return ['', []];
}
if (!class_exists(AbstractPart::class)) {
throw new LogicException('You cannot pass non-empty bodies as the Mime component is not installed. Try running "composer require symfony/mime".');
}
if (null !== $content = $request->getContent()) {
if (isset($headers['content-type'])) {
return [$content, []];
}
$part = new TextPart($content, 'utf-8', 'plain', '8bit');
return [$part->bodyToString(), $part->getPreparedHeaders()->toArray()];
}
$fields = $request->getParameters();
if ($uploadedFiles = $this->getUploadedFiles($request->getFiles())) {
$part = new FormDataPart(array_replace_recursive($fields, $uploadedFiles));
return [$part->bodyToIterable(), $part->getPreparedHeaders()->toArray()];
}
if (!$fields) {
return ['', []];
}
array_walk_recursive($fields, $caster = static function (&$v) use (&$caster) {
if (\is_object($v)) {
if ($vars = get_object_vars($v)) {
array_walk_recursive($vars, $caster);
$v = $vars;
} elseif (method_exists($v, '__toString')) {
$v = (string) $v;
}
}
});
return [http_build_query($fields, '', '&'), ['Content-Type' => 'application/x-www-form-urlencoded']];
}
protected function getHeaders(Request $request): array
{
$headers = [];
foreach ($request->getServer() as $key => $value) {
$key = strtolower(str_replace('_', '-', $key));
$contentHeaders = ['content-length' => true, 'content-md5' => true, 'content-type' => true];
if (str_starts_with($key, 'http-')) {
$headers[substr($key, 5)] = $value;
} elseif (isset($contentHeaders[$key])) {
// CONTENT_* are not prefixed with HTTP_
$headers[$key] = $value;
}
}
$cookies = [];
foreach ($this->getCookieJar()->allRawValues($request->getUri()) as $name => $value) {
$cookies[] = $name.'='.$value;
}
if ($cookies) {
$headers['cookie'] = implode('; ', $cookies);
}
return $headers;
}
/**
* Recursively go through the list. If the file has a tmp_name, convert it to a DataPart.
* Keep the original hierarchy.
*/
private function getUploadedFiles(array $files): array
{
$uploadedFiles = [];
foreach ($files as $name => $file) {
if (!\is_array($file)) {
return $uploadedFiles;
}
if (!isset($file['tmp_name'])) {
$uploadedFiles[$name] = $this->getUploadedFiles($file);
}
if (isset($file['tmp_name'])) {
$uploadedFiles[$name] = DataPart::fromPath($file['tmp_name'], $file['name']);
}
}
return $uploadedFiles;
}
}
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2004-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
BrowserKit Component
====================
The BrowserKit component simulates the behavior of a web browser, allowing you
to make requests, click on links and submit forms programmatically.
The component comes with a concrete implementation that uses the HttpClient
component to make real HTTP requests.
Resources
---------
* [Documentation](https://symfony.com/doc/current/components/browser_kit/introduction.html)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)
+107
View File
@@ -0,0 +1,107 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class Request
{
protected $uri;
protected $method;
protected $parameters;
protected $files;
protected $cookies;
protected $server;
protected $content;
/**
* @param string $uri The request URI
* @param string $method The HTTP method request
* @param array $parameters The request parameters
* @param array $files An array of uploaded files
* @param array $cookies An array of cookies
* @param array $server An array of server parameters
* @param string $content The raw body data
*/
public function __construct(string $uri, string $method, array $parameters = [], array $files = [], array $cookies = [], array $server = [], string $content = null)
{
$this->uri = $uri;
$this->method = $method;
array_walk_recursive($parameters, static function (&$value) {
$value = (string) $value;
});
$this->parameters = $parameters;
$this->files = $files;
$this->cookies = $cookies;
$this->server = $server;
$this->content = $content;
}
/**
* Gets the request URI.
*/
public function getUri(): string
{
return $this->uri;
}
/**
* Gets the request HTTP method.
*/
public function getMethod(): string
{
return $this->method;
}
/**
* Gets the request parameters.
*/
public function getParameters(): array
{
return $this->parameters;
}
/**
* Gets the request server files.
*/
public function getFiles(): array
{
return $this->files;
}
/**
* Gets the request cookies.
*/
public function getCookies(): array
{
return $this->cookies;
}
/**
* Gets the request server parameters.
*/
public function getServer(): array
{
return $this->server;
}
/**
* Gets the request raw body data.
*/
public function getContent(): ?string
{
return $this->content;
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit;
use Symfony\Component\BrowserKit\Exception\JsonException;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
final class Response
{
private string $content;
private int $status;
private array $headers;
private array $jsonData;
/**
* The headers array is a set of key/value pairs. If a header is present multiple times
* then the value is an array of all the values.
*
* @param string $content The content of the response
* @param int $status The response status code (302 "Found" by default)
* @param array $headers An array of headers
*/
public function __construct(string $content = '', int $status = 200, array $headers = [])
{
$this->content = $content;
$this->status = $status;
$this->headers = $headers;
}
/**
* Converts the response object to string containing all headers and the response content.
*/
public function __toString(): string
{
$headers = '';
foreach ($this->headers as $name => $value) {
if (\is_string($value)) {
$headers .= sprintf("%s: %s\n", $name, $value);
} else {
foreach ($value as $headerValue) {
$headers .= sprintf("%s: %s\n", $name, $headerValue);
}
}
}
return $headers."\n".$this->content;
}
public function getContent(): string
{
return $this->content;
}
public function getStatusCode(): int
{
return $this->status;
}
public function getHeaders(): array
{
return $this->headers;
}
/**
* @return string|array|null The first header value if $first is true, an array of values otherwise
*/
public function getHeader(string $header, bool $first = true): string|array|null
{
$normalizedHeader = str_replace('-', '_', strtolower($header));
foreach ($this->headers as $key => $value) {
if (str_replace('-', '_', strtolower($key)) === $normalizedHeader) {
if ($first) {
return \is_array($value) ? (\count($value) ? $value[0] : '') : $value;
}
return \is_array($value) ? $value : [$value];
}
}
return $first ? null : [];
}
public function toArray(): array
{
if (isset($this->jsonData)) {
return $this->jsonData;
}
try {
$content = json_decode($this->content, true, flags: \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new JsonException($e->getMessage(), $e->getCode(), $e);
}
if (!\is_array($content)) {
throw new JsonException(sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content)));
}
return $this->jsonData = $content;
}
}
@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\BrowserKit\AbstractBrowser;
final class BrowserCookieValueSame extends Constraint
{
private string $name;
private string $value;
private bool $raw;
private string $path;
private ?string $domain;
public function __construct(string $name, string $value, bool $raw = false, string $path = '/', string $domain = null)
{
$this->name = $name;
$this->path = $path;
$this->domain = $domain;
$this->value = $value;
$this->raw = $raw;
}
public function toString(): string
{
$str = sprintf('has cookie "%s"', $this->name);
if ('/' !== $this->path) {
$str .= sprintf(' with path "%s"', $this->path);
}
if ($this->domain) {
$str .= sprintf(' for domain "%s"', $this->domain);
}
$str .= sprintf(' with %svalue "%s"', $this->raw ? 'raw ' : '', $this->value);
return $str;
}
/**
* @param AbstractBrowser $browser
*/
protected function matches($browser): bool
{
$cookie = $browser->getCookieJar()->get($this->name, $this->path, $this->domain);
if (!$cookie) {
return false;
}
return $this->value === ($this->raw ? $cookie->getRawValue() : $cookie->getValue());
}
/**
* @param AbstractBrowser $browser
*/
protected function failureDescription($browser): string
{
return 'the Browser '.$this->toString();
}
}
@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\BrowserKit\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\BrowserKit\AbstractBrowser;
final class BrowserHasCookie extends Constraint
{
private string $name;
private string $path;
private ?string $domain;
public function __construct(string $name, string $path = '/', string $domain = null)
{
$this->name = $name;
$this->path = $path;
$this->domain = $domain;
}
public function toString(): string
{
$str = sprintf('has cookie "%s"', $this->name);
if ('/' !== $this->path) {
$str .= sprintf(' with path "%s"', $this->path);
}
if ($this->domain) {
$str .= sprintf(' for domain "%s"', $this->domain);
}
return $str;
}
/**
* @param AbstractBrowser $browser
*/
protected function matches($browser): bool
{
return null !== $browser->getCookieJar()->get($this->name, $this->path, $this->domain);
}
/**
* @param AbstractBrowser $browser
*/
protected function failureDescription($browser): string
{
return 'the Browser '.$this->toString();
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "symfony/browser-kit",
"type": "library",
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
"keywords": [],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.1",
"symfony/dom-crawler": "^5.4|^6.0|^7.0"
},
"require-dev": {
"symfony/css-selector": "^5.4|^6.0|^7.0",
"symfony/http-client": "^5.4|^6.0|^7.0",
"symfony/mime": "^5.4|^6.0|^7.0",
"symfony/process": "^5.4|^6.0|^7.0"
},
"autoload": {
"psr-4": { "Symfony\\Component\\BrowserKit\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}
+23
View File
@@ -0,0 +1,23 @@
CHANGELOG
=========
6.3
-----
* Add support for `:scope`
4.4.0
-----
* Added support for `*:only-of-type`
2.8.0
-----
* Added the `CssSelectorConverter` class as a non-static API for the component.
* Deprecated the `CssSelector` static API of the component.
2.1.0
-----
* none
@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector;
use Symfony\Component\CssSelector\Parser\Shortcut\ClassParser;
use Symfony\Component\CssSelector\Parser\Shortcut\ElementParser;
use Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser;
use Symfony\Component\CssSelector\Parser\Shortcut\HashParser;
use Symfony\Component\CssSelector\XPath\Extension\HtmlExtension;
use Symfony\Component\CssSelector\XPath\Translator;
/**
* CssSelectorConverter is the main entry point of the component and can convert CSS
* selectors to XPath expressions.
*
* @author Christophe Coevoet <stof@notk.org>
*/
class CssSelectorConverter
{
private Translator $translator;
private array $cache;
private static array $xmlCache = [];
private static array $htmlCache = [];
/**
* @param bool $html Whether HTML support should be enabled. Disable it for XML documents
*/
public function __construct(bool $html = true)
{
$this->translator = new Translator();
if ($html) {
$this->translator->registerExtension(new HtmlExtension($this->translator));
$this->cache = &self::$htmlCache;
} else {
$this->cache = &self::$xmlCache;
}
$this->translator
->registerParserShortcut(new EmptyStringParser())
->registerParserShortcut(new ElementParser())
->registerParserShortcut(new ClassParser())
->registerParserShortcut(new HashParser())
;
}
/**
* Translates a CSS expression to its XPath equivalent.
*
* Optionally, a prefix can be added to the resulting XPath
* expression with the $prefix parameter.
*/
public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string
{
return $this->cache[$prefix][$cssExpr] ??= $this->translator->cssToXPath($cssExpr, $prefix);
}
}
@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Exception;
/**
* Interface for exceptions.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*/
interface ExceptionInterface extends \Throwable
{
}
@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Exception;
/**
* ParseException is thrown when a CSS selector syntax is not valid.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*/
class ExpressionErrorException extends ParseException
{
}
@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Exception;
/**
* ParseException is thrown when a CSS selector syntax is not valid.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*/
class InternalErrorException extends ParseException
{
}
@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Exception;
/**
* ParseException is thrown when a CSS selector syntax is not valid.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class ParseException extends \Exception implements ExceptionInterface
{
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Exception;
use Symfony\Component\CssSelector\Parser\Token;
/**
* ParseException is thrown when a CSS selector syntax is not valid.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*/
class SyntaxErrorException extends ParseException
{
public static function unexpectedToken(string $expectedValue, Token $foundToken): self
{
return new self(sprintf('Expected %s, but %s found.', $expectedValue, $foundToken));
}
public static function pseudoElementFound(string $pseudoElement, string $unexpectedLocation): self
{
return new self(sprintf('Unexpected pseudo-element "::%s" found %s.', $pseudoElement, $unexpectedLocation));
}
public static function unclosedString(int $position): self
{
return new self(sprintf('Unclosed/invalid string at %s.', $position));
}
public static function nestedNot(): self
{
return new self('Got nested ::not().');
}
public static function notAtTheStartOfASelector(string $pseudoElement): self
{
return new self(sprintf('Got immediate child pseudo-element ":%s" not at the start of a selector', $pseudoElement));
}
public static function stringAsFunctionArgument(): self
{
return new self('String not allowed as function argument.');
}
}
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2004-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Abstract base node class.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
abstract class AbstractNode implements NodeInterface
{
private string $nodeName;
public function getNodeName(): string
{
return $this->nodeName ??= preg_replace('~.*\\\\([^\\\\]+)Node$~', '$1', static::class);
}
}
@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a "<selector>[<namespace>|<attribute> <operator> <value>]" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class AttributeNode extends AbstractNode
{
private NodeInterface $selector;
private ?string $namespace;
private string $attribute;
private string $operator;
private ?string $value;
public function __construct(NodeInterface $selector, ?string $namespace, string $attribute, string $operator, ?string $value)
{
$this->selector = $selector;
$this->namespace = $namespace;
$this->attribute = $attribute;
$this->operator = $operator;
$this->value = $value;
}
public function getSelector(): NodeInterface
{
return $this->selector;
}
public function getNamespace(): ?string
{
return $this->namespace;
}
public function getAttribute(): string
{
return $this->attribute;
}
public function getOperator(): string
{
return $this->operator;
}
public function getValue(): ?string
{
return $this->value;
}
public function getSpecificity(): Specificity
{
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
}
public function __toString(): string
{
$attribute = $this->namespace ? $this->namespace.'|'.$this->attribute : $this->attribute;
return 'exists' === $this->operator
? sprintf('%s[%s[%s]]', $this->getNodeName(), $this->selector, $attribute)
: sprintf("%s[%s[%s %s '%s']]", $this->getNodeName(), $this->selector, $attribute, $this->operator, $this->value);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a "<selector>.<name>" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class ClassNode extends AbstractNode
{
private NodeInterface $selector;
private string $name;
public function __construct(NodeInterface $selector, string $name)
{
$this->selector = $selector;
$this->name = $name;
}
public function getSelector(): NodeInterface
{
return $this->selector;
}
public function getName(): string
{
return $this->name;
}
public function getSpecificity(): Specificity
{
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
}
public function __toString(): string
{
return sprintf('%s[%s.%s]', $this->getNodeName(), $this->selector, $this->name);
}
}
@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a combined node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class CombinedSelectorNode extends AbstractNode
{
private NodeInterface $selector;
private string $combinator;
private NodeInterface $subSelector;
public function __construct(NodeInterface $selector, string $combinator, NodeInterface $subSelector)
{
$this->selector = $selector;
$this->combinator = $combinator;
$this->subSelector = $subSelector;
}
public function getSelector(): NodeInterface
{
return $this->selector;
}
public function getCombinator(): string
{
return $this->combinator;
}
public function getSubSelector(): NodeInterface
{
return $this->subSelector;
}
public function getSpecificity(): Specificity
{
return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity());
}
public function __toString(): string
{
$combinator = ' ' === $this->combinator ? '<followed>' : $this->combinator;
return sprintf('%s[%s %s %s]', $this->getNodeName(), $this->selector, $combinator, $this->subSelector);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a "<namespace>|<element>" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class ElementNode extends AbstractNode
{
private ?string $namespace;
private ?string $element;
public function __construct(string $namespace = null, string $element = null)
{
$this->namespace = $namespace;
$this->element = $element;
}
public function getNamespace(): ?string
{
return $this->namespace;
}
public function getElement(): ?string
{
return $this->element;
}
public function getSpecificity(): Specificity
{
return new Specificity(0, 0, $this->element ? 1 : 0);
}
public function __toString(): string
{
$element = $this->element ?: '*';
return sprintf('%s[%s]', $this->getNodeName(), $this->namespace ? $this->namespace.'|'.$element : $element);
}
}
@@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
use Symfony\Component\CssSelector\Parser\Token;
/**
* Represents a "<selector>:<name>(<arguments>)" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class FunctionNode extends AbstractNode
{
private NodeInterface $selector;
private string $name;
private array $arguments;
/**
* @param Token[] $arguments
*/
public function __construct(NodeInterface $selector, string $name, array $arguments = [])
{
$this->selector = $selector;
$this->name = strtolower($name);
$this->arguments = $arguments;
}
public function getSelector(): NodeInterface
{
return $this->selector;
}
public function getName(): string
{
return $this->name;
}
/**
* @return Token[]
*/
public function getArguments(): array
{
return $this->arguments;
}
public function getSpecificity(): Specificity
{
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
}
public function __toString(): string
{
$arguments = implode(', ', array_map(fn (Token $token) => "'".$token->getValue()."'", $this->arguments));
return sprintf('%s[%s:%s(%s)]', $this->getNodeName(), $this->selector, $this->name, $arguments ? '['.$arguments.']' : '');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a "<selector>#<id>" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class HashNode extends AbstractNode
{
private NodeInterface $selector;
private string $id;
public function __construct(NodeInterface $selector, string $id)
{
$this->selector = $selector;
$this->id = $id;
}
public function getSelector(): NodeInterface
{
return $this->selector;
}
public function getId(): string
{
return $this->id;
}
public function getSpecificity(): Specificity
{
return $this->selector->getSpecificity()->plus(new Specificity(1, 0, 0));
}
public function __toString(): string
{
return sprintf('%s[%s#%s]', $this->getNodeName(), $this->selector, $this->id);
}
}
@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a "<selector>:not(<identifier>)" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class NegationNode extends AbstractNode
{
private NodeInterface $selector;
private NodeInterface $subSelector;
public function __construct(NodeInterface $selector, NodeInterface $subSelector)
{
$this->selector = $selector;
$this->subSelector = $subSelector;
}
public function getSelector(): NodeInterface
{
return $this->selector;
}
public function getSubSelector(): NodeInterface
{
return $this->subSelector;
}
public function getSpecificity(): Specificity
{
return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity());
}
public function __toString(): string
{
return sprintf('%s[%s:not(%s)]', $this->getNodeName(), $this->selector, $this->subSelector);
}
}
@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Interface for nodes.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
interface NodeInterface extends \Stringable
{
public function getNodeName(): string;
public function getSpecificity(): Specificity;
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a "<selector>:<identifier>" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class PseudoNode extends AbstractNode
{
private NodeInterface $selector;
private string $identifier;
public function __construct(NodeInterface $selector, string $identifier)
{
$this->selector = $selector;
$this->identifier = strtolower($identifier);
}
public function getSelector(): NodeInterface
{
return $this->selector;
}
public function getIdentifier(): string
{
return $this->identifier;
}
public function getSpecificity(): Specificity
{
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
}
public function __toString(): string
{
return sprintf('%s[%s:%s]', $this->getNodeName(), $this->selector, $this->identifier);
}
}
@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a "<selector>(::|:)<pseudoElement>" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class SelectorNode extends AbstractNode
{
private NodeInterface $tree;
private ?string $pseudoElement;
public function __construct(NodeInterface $tree, string $pseudoElement = null)
{
$this->tree = $tree;
$this->pseudoElement = $pseudoElement ? strtolower($pseudoElement) : null;
}
public function getTree(): NodeInterface
{
return $this->tree;
}
public function getPseudoElement(): ?string
{
return $this->pseudoElement;
}
public function getSpecificity(): Specificity
{
return $this->tree->getSpecificity()->plus(new Specificity(0, 0, $this->pseudoElement ? 1 : 0));
}
public function __toString(): string
{
return sprintf('%s[%s%s]', $this->getNodeName(), $this->tree, $this->pseudoElement ? '::'.$this->pseudoElement : '');
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Node;
/**
* Represents a node specificity.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @see http://www.w3.org/TR/selectors/#specificity
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class Specificity
{
public const A_FACTOR = 100;
public const B_FACTOR = 10;
public const C_FACTOR = 1;
private int $a;
private int $b;
private int $c;
public function __construct(int $a, int $b, int $c)
{
$this->a = $a;
$this->b = $b;
$this->c = $c;
}
public function plus(self $specificity): self
{
return new self($this->a + $specificity->a, $this->b + $specificity->b, $this->c + $specificity->c);
}
public function getValue(): int
{
return $this->a * self::A_FACTOR + $this->b * self::B_FACTOR + $this->c * self::C_FACTOR;
}
/**
* Returns -1 if the object specificity is lower than the argument,
* 0 if they are equal, and 1 if the argument is lower.
*/
public function compareTo(self $specificity): int
{
if ($this->a !== $specificity->a) {
return $this->a > $specificity->a ? 1 : -1;
}
if ($this->b !== $specificity->b) {
return $this->b > $specificity->b ? 1 : -1;
}
if ($this->c !== $specificity->c) {
return $this->c > $specificity->c ? 1 : -1;
}
return 0;
}
}
@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Handler;
use Symfony\Component\CssSelector\Parser\Reader;
use Symfony\Component\CssSelector\Parser\TokenStream;
/**
* CSS selector comment handler.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class CommentHandler implements HandlerInterface
{
public function handle(Reader $reader, TokenStream $stream): bool
{
if ('/*' !== $reader->getSubstring(2)) {
return false;
}
$offset = $reader->getOffset('*/');
if (false === $offset) {
$reader->moveToEnd();
} else {
$reader->moveForward($offset + 2);
}
return true;
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Handler;
use Symfony\Component\CssSelector\Parser\Reader;
use Symfony\Component\CssSelector\Parser\TokenStream;
/**
* CSS selector handler interface.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
interface HandlerInterface
{
public function handle(Reader $reader, TokenStream $stream): bool;
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Handler;
use Symfony\Component\CssSelector\Parser\Reader;
use Symfony\Component\CssSelector\Parser\Token;
use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
use Symfony\Component\CssSelector\Parser\TokenStream;
/**
* CSS selector comment handler.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class HashHandler implements HandlerInterface
{
private TokenizerPatterns $patterns;
private TokenizerEscaping $escaping;
public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
{
$this->patterns = $patterns;
$this->escaping = $escaping;
}
public function handle(Reader $reader, TokenStream $stream): bool
{
$match = $reader->findPattern($this->patterns->getHashPattern());
if (!$match) {
return false;
}
$value = $this->escaping->escapeUnicode($match[1]);
$stream->push(new Token(Token::TYPE_HASH, $value, $reader->getPosition()));
$reader->moveForward(\strlen($match[0]));
return true;
}
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Handler;
use Symfony\Component\CssSelector\Parser\Reader;
use Symfony\Component\CssSelector\Parser\Token;
use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
use Symfony\Component\CssSelector\Parser\TokenStream;
/**
* CSS selector comment handler.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class IdentifierHandler implements HandlerInterface
{
private TokenizerPatterns $patterns;
private TokenizerEscaping $escaping;
public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
{
$this->patterns = $patterns;
$this->escaping = $escaping;
}
public function handle(Reader $reader, TokenStream $stream): bool
{
$match = $reader->findPattern($this->patterns->getIdentifierPattern());
if (!$match) {
return false;
}
$value = $this->escaping->escapeUnicode($match[0]);
$stream->push(new Token(Token::TYPE_IDENTIFIER, $value, $reader->getPosition()));
$reader->moveForward(\strlen($match[0]));
return true;
}
}
@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Handler;
use Symfony\Component\CssSelector\Parser\Reader;
use Symfony\Component\CssSelector\Parser\Token;
use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
use Symfony\Component\CssSelector\Parser\TokenStream;
/**
* CSS selector comment handler.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class NumberHandler implements HandlerInterface
{
private TokenizerPatterns $patterns;
public function __construct(TokenizerPatterns $patterns)
{
$this->patterns = $patterns;
}
public function handle(Reader $reader, TokenStream $stream): bool
{
$match = $reader->findPattern($this->patterns->getNumberPattern());
if (!$match) {
return false;
}
$stream->push(new Token(Token::TYPE_NUMBER, $match[0], $reader->getPosition()));
$reader->moveForward(\strlen($match[0]));
return true;
}
}
@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Handler;
use Symfony\Component\CssSelector\Exception\InternalErrorException;
use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
use Symfony\Component\CssSelector\Parser\Reader;
use Symfony\Component\CssSelector\Parser\Token;
use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
use Symfony\Component\CssSelector\Parser\TokenStream;
/**
* CSS selector comment handler.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class StringHandler implements HandlerInterface
{
private TokenizerPatterns $patterns;
private TokenizerEscaping $escaping;
public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
{
$this->patterns = $patterns;
$this->escaping = $escaping;
}
public function handle(Reader $reader, TokenStream $stream): bool
{
$quote = $reader->getSubstring(1);
if (!\in_array($quote, ["'", '"'])) {
return false;
}
$reader->moveForward(1);
$match = $reader->findPattern($this->patterns->getQuotedStringPattern($quote));
if (!$match) {
throw new InternalErrorException(sprintf('Should have found at least an empty match at %d.', $reader->getPosition()));
}
// check unclosed strings
if (\strlen($match[0]) === $reader->getRemainingLength()) {
throw SyntaxErrorException::unclosedString($reader->getPosition() - 1);
}
// check quotes pairs validity
if ($quote !== $reader->getSubstring(1, \strlen($match[0]))) {
throw SyntaxErrorException::unclosedString($reader->getPosition() - 1);
}
$string = $this->escaping->escapeUnicodeAndNewLine($match[0]);
$stream->push(new Token(Token::TYPE_STRING, $string, $reader->getPosition()));
$reader->moveForward(\strlen($match[0]) + 1);
return true;
}
}
@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Handler;
use Symfony\Component\CssSelector\Parser\Reader;
use Symfony\Component\CssSelector\Parser\Token;
use Symfony\Component\CssSelector\Parser\TokenStream;
/**
* CSS selector whitespace handler.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class WhitespaceHandler implements HandlerInterface
{
public function handle(Reader $reader, TokenStream $stream): bool
{
$match = $reader->findPattern('~^[ \t\r\n\f]+~');
if (false === $match) {
return false;
}
$stream->push(new Token(Token::TYPE_WHITESPACE, $match[0], $reader->getPosition()));
$reader->moveForward(\strlen($match[0]));
return true;
}
}
+359
View File
@@ -0,0 +1,359 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser;
use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
use Symfony\Component\CssSelector\Node;
use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer;
/**
* CSS selector parser.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class Parser implements ParserInterface
{
private Tokenizer $tokenizer;
public function __construct(Tokenizer $tokenizer = null)
{
$this->tokenizer = $tokenizer ?? new Tokenizer();
}
public function parse(string $source): array
{
$reader = new Reader($source);
$stream = $this->tokenizer->tokenize($reader);
return $this->parseSelectorList($stream);
}
/**
* Parses the arguments for ":nth-child()" and friends.
*
* @param Token[] $tokens
*
* @throws SyntaxErrorException
*/
public static function parseSeries(array $tokens): array
{
foreach ($tokens as $token) {
if ($token->isString()) {
throw SyntaxErrorException::stringAsFunctionArgument();
}
}
$joined = trim(implode('', array_map(fn (Token $token) => $token->getValue(), $tokens)));
$int = function ($string) {
if (!is_numeric($string)) {
throw SyntaxErrorException::stringAsFunctionArgument();
}
return (int) $string;
};
switch (true) {
case 'odd' === $joined:
return [2, 1];
case 'even' === $joined:
return [2, 0];
case 'n' === $joined:
return [1, 0];
case !str_contains($joined, 'n'):
return [0, $int($joined)];
}
$split = explode('n', $joined);
$first = $split[0] ?? null;
return [
$first ? ('-' === $first || '+' === $first ? $int($first.'1') : $int($first)) : 1,
isset($split[1]) && $split[1] ? $int($split[1]) : 0,
];
}
private function parseSelectorList(TokenStream $stream): array
{
$stream->skipWhitespace();
$selectors = [];
while (true) {
$selectors[] = $this->parserSelectorNode($stream);
if ($stream->getPeek()->isDelimiter([','])) {
$stream->getNext();
$stream->skipWhitespace();
} else {
break;
}
}
return $selectors;
}
private function parserSelectorNode(TokenStream $stream): Node\SelectorNode
{
[$result, $pseudoElement] = $this->parseSimpleSelector($stream);
while (true) {
$stream->skipWhitespace();
$peek = $stream->getPeek();
if ($peek->isFileEnd() || $peek->isDelimiter([','])) {
break;
}
if (null !== $pseudoElement) {
throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector');
}
if ($peek->isDelimiter(['+', '>', '~'])) {
$combinator = $stream->getNext()->getValue();
$stream->skipWhitespace();
} else {
$combinator = ' ';
}
[$nextSelector, $pseudoElement] = $this->parseSimpleSelector($stream);
$result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector);
}
return new Node\SelectorNode($result, $pseudoElement);
}
/**
* Parses next simple node (hash, class, pseudo, negation).
*
* @throws SyntaxErrorException
*/
private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false): array
{
$stream->skipWhitespace();
$selectorStart = \count($stream->getUsed());
$result = $this->parseElementNode($stream);
$pseudoElement = null;
while (true) {
$peek = $stream->getPeek();
if ($peek->isWhitespace()
|| $peek->isFileEnd()
|| $peek->isDelimiter([',', '+', '>', '~'])
|| ($insideNegation && $peek->isDelimiter([')']))
) {
break;
}
if (null !== $pseudoElement) {
throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector');
}
if ($peek->isHash()) {
$result = new Node\HashNode($result, $stream->getNext()->getValue());
} elseif ($peek->isDelimiter(['.'])) {
$stream->getNext();
$result = new Node\ClassNode($result, $stream->getNextIdentifier());
} elseif ($peek->isDelimiter(['['])) {
$stream->getNext();
$result = $this->parseAttributeNode($result, $stream);
} elseif ($peek->isDelimiter([':'])) {
$stream->getNext();
if ($stream->getPeek()->isDelimiter([':'])) {
$stream->getNext();
$pseudoElement = $stream->getNextIdentifier();
continue;
}
$identifier = $stream->getNextIdentifier();
if (\in_array(strtolower($identifier), ['first-line', 'first-letter', 'before', 'after'])) {
// Special case: CSS 2.1 pseudo-elements can have a single ':'.
// Any new pseudo-element must have two.
$pseudoElement = $identifier;
continue;
}
if (!$stream->getPeek()->isDelimiter(['('])) {
$result = new Node\PseudoNode($result, $identifier);
if ('Pseudo[Element[*]:scope]' === $result->__toString()) {
$used = \count($stream->getUsed());
if (!(2 === $used
|| 3 === $used && $stream->getUsed()[0]->isWhiteSpace()
|| $used >= 3 && $stream->getUsed()[$used - 3]->isDelimiter([','])
|| $used >= 4
&& $stream->getUsed()[$used - 3]->isWhiteSpace()
&& $stream->getUsed()[$used - 4]->isDelimiter([','])
)) {
throw SyntaxErrorException::notAtTheStartOfASelector('scope');
}
}
continue;
}
$stream->getNext();
$stream->skipWhitespace();
if ('not' === strtolower($identifier)) {
if ($insideNegation) {
throw SyntaxErrorException::nestedNot();
}
[$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true);
$next = $stream->getNext();
if (null !== $argumentPseudoElement) {
throw SyntaxErrorException::pseudoElementFound($argumentPseudoElement, 'inside ::not()');
}
if (!$next->isDelimiter([')'])) {
throw SyntaxErrorException::unexpectedToken('")"', $next);
}
$result = new Node\NegationNode($result, $argument);
} else {
$arguments = [];
$next = null;
while (true) {
$stream->skipWhitespace();
$next = $stream->getNext();
if ($next->isIdentifier()
|| $next->isString()
|| $next->isNumber()
|| $next->isDelimiter(['+', '-'])
) {
$arguments[] = $next;
} elseif ($next->isDelimiter([')'])) {
break;
} else {
throw SyntaxErrorException::unexpectedToken('an argument', $next);
}
}
if (!$arguments) {
throw SyntaxErrorException::unexpectedToken('at least one argument', $next);
}
$result = new Node\FunctionNode($result, $identifier, $arguments);
}
} else {
throw SyntaxErrorException::unexpectedToken('selector', $peek);
}
}
if (\count($stream->getUsed()) === $selectorStart) {
throw SyntaxErrorException::unexpectedToken('selector', $stream->getPeek());
}
return [$result, $pseudoElement];
}
private function parseElementNode(TokenStream $stream): Node\ElementNode
{
$peek = $stream->getPeek();
if ($peek->isIdentifier() || $peek->isDelimiter(['*'])) {
if ($peek->isIdentifier()) {
$namespace = $stream->getNext()->getValue();
} else {
$stream->getNext();
$namespace = null;
}
if ($stream->getPeek()->isDelimiter(['|'])) {
$stream->getNext();
$element = $stream->getNextIdentifierOrStar();
} else {
$element = $namespace;
$namespace = null;
}
} else {
$element = $namespace = null;
}
return new Node\ElementNode($namespace, $element);
}
private function parseAttributeNode(Node\NodeInterface $selector, TokenStream $stream): Node\AttributeNode
{
$stream->skipWhitespace();
$attribute = $stream->getNextIdentifierOrStar();
if (null === $attribute && !$stream->getPeek()->isDelimiter(['|'])) {
throw SyntaxErrorException::unexpectedToken('"|"', $stream->getPeek());
}
if ($stream->getPeek()->isDelimiter(['|'])) {
$stream->getNext();
if ($stream->getPeek()->isDelimiter(['='])) {
$namespace = null;
$stream->getNext();
$operator = '|=';
} else {
$namespace = $attribute;
$attribute = $stream->getNextIdentifier();
$operator = null;
}
} else {
$namespace = $operator = null;
}
if (null === $operator) {
$stream->skipWhitespace();
$next = $stream->getNext();
if ($next->isDelimiter([']'])) {
return new Node\AttributeNode($selector, $namespace, $attribute, 'exists', null);
} elseif ($next->isDelimiter(['='])) {
$operator = '=';
} elseif ($next->isDelimiter(['^', '$', '*', '~', '|', '!'])
&& $stream->getPeek()->isDelimiter(['='])
) {
$operator = $next->getValue().'=';
$stream->getNext();
} else {
throw SyntaxErrorException::unexpectedToken('operator', $next);
}
}
$stream->skipWhitespace();
$value = $stream->getNext();
if ($value->isNumber()) {
// if the value is a number, it's casted into a string
$value = new Token(Token::TYPE_STRING, (string) $value->getValue(), $value->getPosition());
}
if (!($value->isIdentifier() || $value->isString())) {
throw SyntaxErrorException::unexpectedToken('string or identifier', $value);
}
$stream->skipWhitespace();
$next = $stream->getNext();
if (!$next->isDelimiter([']'])) {
throw SyntaxErrorException::unexpectedToken('"]"', $next);
}
return new Node\AttributeNode($selector, $namespace, $attribute, $operator, $value->getValue());
}
}
@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser;
use Symfony\Component\CssSelector\Node\SelectorNode;
/**
* CSS selector parser interface.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
interface ParserInterface
{
/**
* Parses given selector source into an array of tokens.
*
* @return SelectorNode[]
*/
public function parse(string $source): array;
}
+86
View File
@@ -0,0 +1,86 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser;
/**
* CSS selector reader.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class Reader
{
private string $source;
private int $length;
private int $position = 0;
public function __construct(string $source)
{
$this->source = $source;
$this->length = \strlen($source);
}
public function isEOF(): bool
{
return $this->position >= $this->length;
}
public function getPosition(): int
{
return $this->position;
}
public function getRemainingLength(): int
{
return $this->length - $this->position;
}
public function getSubstring(int $length, int $offset = 0): string
{
return substr($this->source, $this->position + $offset, $length);
}
/**
* @return int|false
*/
public function getOffset(string $string): int|bool
{
$position = strpos($this->source, $string, $this->position);
return false === $position ? false : $position - $this->position;
}
public function findPattern(string $pattern): array|false
{
$source = substr($this->source, $this->position);
if (preg_match($pattern, $source, $matches)) {
return $matches;
}
return false;
}
public function moveForward(int $length): void
{
$this->position += $length;
}
public function moveToEnd(): void
{
$this->position = $this->length;
}
}
@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Shortcut;
use Symfony\Component\CssSelector\Node\ClassNode;
use Symfony\Component\CssSelector\Node\ElementNode;
use Symfony\Component\CssSelector\Node\SelectorNode;
use Symfony\Component\CssSelector\Parser\ParserInterface;
/**
* CSS selector class parser shortcut.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class ClassParser implements ParserInterface
{
public function parse(string $source): array
{
// Matches an optional namespace, optional element, and required class
// $source = 'test|input.ab6bd_field';
// $matches = array (size=4)
// 0 => string 'test|input.ab6bd_field' (length=22)
// 1 => string 'test' (length=4)
// 2 => string 'input' (length=5)
// 3 => string 'ab6bd_field' (length=11)
if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+\.([\w-]++)$/i', trim($source), $matches)) {
return [
new SelectorNode(new ClassNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])),
];
}
return [];
}
}
@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Shortcut;
use Symfony\Component\CssSelector\Node\ElementNode;
use Symfony\Component\CssSelector\Node\SelectorNode;
use Symfony\Component\CssSelector\Parser\ParserInterface;
/**
* CSS selector element parser shortcut.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class ElementParser implements ParserInterface
{
public function parse(string $source): array
{
// Matches an optional namespace, required element or `*`
// $source = 'testns|testel';
// $matches = array (size=3)
// 0 => string 'testns|testel' (length=13)
// 1 => string 'testns' (length=6)
// 2 => string 'testel' (length=6)
if (preg_match('/^(?:([a-z]++)\|)?([\w-]++|\*)$/i', trim($source), $matches)) {
return [new SelectorNode(new ElementNode($matches[1] ?: null, $matches[2]))];
}
return [];
}
}
@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Shortcut;
use Symfony\Component\CssSelector\Node\ElementNode;
use Symfony\Component\CssSelector\Node\SelectorNode;
use Symfony\Component\CssSelector\Parser\ParserInterface;
/**
* CSS selector class parser shortcut.
*
* This shortcut ensure compatibility with previous version.
* - The parser fails to parse an empty string.
* - In the previous version, an empty string matches each tags.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class EmptyStringParser implements ParserInterface
{
public function parse(string $source): array
{
// Matches an empty string
if ('' == $source) {
return [new SelectorNode(new ElementNode(null, '*'))];
}
return [];
}
}
@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Shortcut;
use Symfony\Component\CssSelector\Node\ElementNode;
use Symfony\Component\CssSelector\Node\HashNode;
use Symfony\Component\CssSelector\Node\SelectorNode;
use Symfony\Component\CssSelector\Parser\ParserInterface;
/**
* CSS selector hash parser shortcut.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class HashParser implements ParserInterface
{
public function parse(string $source): array
{
// Matches an optional namespace, optional element, and required id
// $source = 'test|input#ab6bd_field';
// $matches = array (size=4)
// 0 => string 'test|input#ab6bd_field' (length=22)
// 1 => string 'test' (length=4)
// 2 => string 'input' (length=5)
// 3 => string 'ab6bd_field' (length=11)
if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+#([\w-]++)$/i', trim($source), $matches)) {
return [
new SelectorNode(new HashNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])),
];
}
return [];
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser;
/**
* CSS selector token.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class Token
{
public const TYPE_FILE_END = 'eof';
public const TYPE_DELIMITER = 'delimiter';
public const TYPE_WHITESPACE = 'whitespace';
public const TYPE_IDENTIFIER = 'identifier';
public const TYPE_HASH = 'hash';
public const TYPE_NUMBER = 'number';
public const TYPE_STRING = 'string';
private ?string $type;
private ?string $value;
private ?int $position;
public function __construct(?string $type, ?string $value, ?int $position)
{
$this->type = $type;
$this->value = $value;
$this->position = $position;
}
public function getType(): ?int
{
return $this->type;
}
public function getValue(): ?string
{
return $this->value;
}
public function getPosition(): ?int
{
return $this->position;
}
public function isFileEnd(): bool
{
return self::TYPE_FILE_END === $this->type;
}
public function isDelimiter(array $values = []): bool
{
if (self::TYPE_DELIMITER !== $this->type) {
return false;
}
if (!$values) {
return true;
}
return \in_array($this->value, $values);
}
public function isWhitespace(): bool
{
return self::TYPE_WHITESPACE === $this->type;
}
public function isIdentifier(): bool
{
return self::TYPE_IDENTIFIER === $this->type;
}
public function isHash(): bool
{
return self::TYPE_HASH === $this->type;
}
public function isNumber(): bool
{
return self::TYPE_NUMBER === $this->type;
}
public function isString(): bool
{
return self::TYPE_STRING === $this->type;
}
public function __toString(): string
{
if ($this->value) {
return sprintf('<%s "%s" at %s>', $this->type, $this->value, $this->position);
}
return sprintf('<%s at %s>', $this->type, $this->position);
}
}
@@ -0,0 +1,156 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser;
use Symfony\Component\CssSelector\Exception\InternalErrorException;
use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
/**
* CSS selector token stream.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class TokenStream
{
/**
* @var Token[]
*/
private array $tokens = [];
/**
* @var Token[]
*/
private array $used = [];
private int $cursor = 0;
private ?Token $peeked;
private bool $peeking = false;
/**
* Pushes a token.
*
* @return $this
*/
public function push(Token $token): static
{
$this->tokens[] = $token;
return $this;
}
/**
* Freezes stream.
*
* @return $this
*/
public function freeze(): static
{
return $this;
}
/**
* Returns next token.
*
* @throws InternalErrorException If there is no more token
*/
public function getNext(): Token
{
if ($this->peeking) {
$this->peeking = false;
$this->used[] = $this->peeked;
return $this->peeked;
}
if (!isset($this->tokens[$this->cursor])) {
throw new InternalErrorException('Unexpected token stream end.');
}
return $this->tokens[$this->cursor++];
}
/**
* Returns peeked token.
*/
public function getPeek(): Token
{
if (!$this->peeking) {
$this->peeked = $this->getNext();
$this->peeking = true;
}
return $this->peeked;
}
/**
* Returns used tokens.
*
* @return Token[]
*/
public function getUsed(): array
{
return $this->used;
}
/**
* Returns next identifier token.
*
* @throws SyntaxErrorException If next token is not an identifier
*/
public function getNextIdentifier(): string
{
$next = $this->getNext();
if (!$next->isIdentifier()) {
throw SyntaxErrorException::unexpectedToken('identifier', $next);
}
return $next->getValue();
}
/**
* Returns next identifier or null if star delimiter token is found.
*
* @throws SyntaxErrorException If next token is not an identifier or a star delimiter
*/
public function getNextIdentifierOrStar(): ?string
{
$next = $this->getNext();
if ($next->isIdentifier()) {
return $next->getValue();
}
if ($next->isDelimiter(['*'])) {
return null;
}
throw SyntaxErrorException::unexpectedToken('identifier or "*"', $next);
}
/**
* Skips next whitespace if any.
*/
public function skipWhitespace(): void
{
$peek = $this->getPeek();
if ($peek->isWhitespace()) {
$this->getNext();
}
}
}
@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Tokenizer;
use Symfony\Component\CssSelector\Parser\Handler;
use Symfony\Component\CssSelector\Parser\Reader;
use Symfony\Component\CssSelector\Parser\Token;
use Symfony\Component\CssSelector\Parser\TokenStream;
/**
* CSS selector tokenizer.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class Tokenizer
{
/**
* @var Handler\HandlerInterface[]
*/
private array $handlers;
public function __construct()
{
$patterns = new TokenizerPatterns();
$escaping = new TokenizerEscaping($patterns);
$this->handlers = [
new Handler\WhitespaceHandler(),
new Handler\IdentifierHandler($patterns, $escaping),
new Handler\HashHandler($patterns, $escaping),
new Handler\StringHandler($patterns, $escaping),
new Handler\NumberHandler($patterns),
new Handler\CommentHandler(),
];
}
/**
* Tokenize selector source code.
*/
public function tokenize(Reader $reader): TokenStream
{
$stream = new TokenStream();
while (!$reader->isEOF()) {
foreach ($this->handlers as $handler) {
if ($handler->handle($reader, $stream)) {
continue 2;
}
}
$stream->push(new Token(Token::TYPE_DELIMITER, $reader->getSubstring(1), $reader->getPosition()));
$reader->moveForward(1);
}
return $stream
->push(new Token(Token::TYPE_FILE_END, null, $reader->getPosition()))
->freeze();
}
}
@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Tokenizer;
/**
* CSS selector tokenizer escaping applier.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class TokenizerEscaping
{
private TokenizerPatterns $patterns;
public function __construct(TokenizerPatterns $patterns)
{
$this->patterns = $patterns;
}
public function escapeUnicode(string $value): string
{
$value = $this->replaceUnicodeSequences($value);
return preg_replace($this->patterns->getSimpleEscapePattern(), '$1', $value);
}
public function escapeUnicodeAndNewLine(string $value): string
{
$value = preg_replace($this->patterns->getNewLineEscapePattern(), '', $value);
return $this->escapeUnicode($value);
}
private function replaceUnicodeSequences(string $value): string
{
return preg_replace_callback($this->patterns->getUnicodeEscapePattern(), function ($match) {
$c = hexdec($match[1]);
if (0x80 > $c %= 0x200000) {
return \chr($c);
}
if (0x800 > $c) {
return \chr(0xC0 | $c >> 6).\chr(0x80 | $c & 0x3F);
}
if (0x10000 > $c) {
return \chr(0xE0 | $c >> 12).\chr(0x80 | $c >> 6 & 0x3F).\chr(0x80 | $c & 0x3F);
}
return '';
}, $value);
}
}
@@ -0,0 +1,89 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\Parser\Tokenizer;
/**
* CSS selector tokenizer patterns builder.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class TokenizerPatterns
{
private string $unicodeEscapePattern;
private string $simpleEscapePattern;
private string $newLineEscapePattern;
private string $escapePattern;
private string $stringEscapePattern;
private string $nonAsciiPattern;
private string $nmCharPattern;
private string $nmStartPattern;
private string $identifierPattern;
private string $hashPattern;
private string $numberPattern;
private string $quotedStringPattern;
public function __construct()
{
$this->unicodeEscapePattern = '\\\\([0-9a-f]{1,6})(?:\r\n|[ \n\r\t\f])?';
$this->simpleEscapePattern = '\\\\(.)';
$this->newLineEscapePattern = '\\\\(?:\n|\r\n|\r|\f)';
$this->escapePattern = $this->unicodeEscapePattern.'|\\\\[^\n\r\f0-9a-f]';
$this->stringEscapePattern = $this->newLineEscapePattern.'|'.$this->escapePattern;
$this->nonAsciiPattern = '[^\x00-\x7F]';
$this->nmCharPattern = '[_a-z0-9-]|'.$this->escapePattern.'|'.$this->nonAsciiPattern;
$this->nmStartPattern = '[_a-z]|'.$this->escapePattern.'|'.$this->nonAsciiPattern;
$this->identifierPattern = '-?(?:'.$this->nmStartPattern.')(?:'.$this->nmCharPattern.')*';
$this->hashPattern = '#((?:'.$this->nmCharPattern.')+)';
$this->numberPattern = '[+-]?(?:[0-9]*\.[0-9]+|[0-9]+)';
$this->quotedStringPattern = '([^\n\r\f\\\\%s]|'.$this->stringEscapePattern.')*';
}
public function getNewLineEscapePattern(): string
{
return '~'.$this->newLineEscapePattern.'~';
}
public function getSimpleEscapePattern(): string
{
return '~'.$this->simpleEscapePattern.'~';
}
public function getUnicodeEscapePattern(): string
{
return '~'.$this->unicodeEscapePattern.'~i';
}
public function getIdentifierPattern(): string
{
return '~^'.$this->identifierPattern.'~i';
}
public function getHashPattern(): string
{
return '~^'.$this->hashPattern.'~i';
}
public function getNumberPattern(): string
{
return '~^'.$this->numberPattern.'~';
}
public function getQuotedStringPattern(string $quote): string
{
return '~^'.sprintf($this->quotedStringPattern, $quote).'~i';
}
}
+20
View File
@@ -0,0 +1,20 @@
CssSelector Component
=====================
The CssSelector component converts CSS selectors to XPath expressions.
Resources
---------
* [Documentation](https://symfony.com/doc/current/components/css_selector.html)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)
Credits
-------
This component is a port of the Python cssselect library
[v0.7.1](https://github.com/SimonSapin/cssselect/releases/tag/v0.7.1),
which is distributed under the BSD license.
@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath\Extension;
/**
* XPath expression translator abstract extension.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
abstract class AbstractExtension implements ExtensionInterface
{
public function getNodeTranslators(): array
{
return [];
}
public function getCombinationTranslators(): array
{
return [];
}
public function getFunctionTranslators(): array
{
return [];
}
public function getPseudoClassTranslators(): array
{
return [];
}
public function getAttributeMatchingTranslators(): array
{
return [];
}
}
@@ -0,0 +1,113 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath\Extension;
use Symfony\Component\CssSelector\XPath\Translator;
use Symfony\Component\CssSelector\XPath\XPathExpr;
/**
* XPath expression translator attribute extension.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class AttributeMatchingExtension extends AbstractExtension
{
public function getAttributeMatchingTranslators(): array
{
return [
'exists' => $this->translateExists(...),
'=' => $this->translateEquals(...),
'~=' => $this->translateIncludes(...),
'|=' => $this->translateDashMatch(...),
'^=' => $this->translatePrefixMatch(...),
'$=' => $this->translateSuffixMatch(...),
'*=' => $this->translateSubstringMatch(...),
'!=' => $this->translateDifferent(...),
];
}
public function translateExists(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
{
return $xpath->addCondition($attribute);
}
public function translateEquals(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
{
return $xpath->addCondition(sprintf('%s = %s', $attribute, Translator::getXpathLiteral($value)));
}
public function translateIncludes(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
{
return $xpath->addCondition($value ? sprintf(
'%1$s and contains(concat(\' \', normalize-space(%1$s), \' \'), %2$s)',
$attribute,
Translator::getXpathLiteral(' '.$value.' ')
) : '0');
}
public function translateDashMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
{
return $xpath->addCondition(sprintf(
'%1$s and (%1$s = %2$s or starts-with(%1$s, %3$s))',
$attribute,
Translator::getXpathLiteral($value),
Translator::getXpathLiteral($value.'-')
));
}
public function translatePrefixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
{
return $xpath->addCondition($value ? sprintf(
'%1$s and starts-with(%1$s, %2$s)',
$attribute,
Translator::getXpathLiteral($value)
) : '0');
}
public function translateSuffixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
{
return $xpath->addCondition($value ? sprintf(
'%1$s and substring(%1$s, string-length(%1$s)-%2$s) = %3$s',
$attribute,
\strlen($value) - 1,
Translator::getXpathLiteral($value)
) : '0');
}
public function translateSubstringMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
{
return $xpath->addCondition($value ? sprintf(
'%1$s and contains(%1$s, %2$s)',
$attribute,
Translator::getXpathLiteral($value)
) : '0');
}
public function translateDifferent(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
{
return $xpath->addCondition(sprintf(
$value ? 'not(%1$s) or %1$s != %2$s' : '%s != %s',
$attribute,
Translator::getXpathLiteral($value)
));
}
public function getName(): string
{
return 'attribute-matching';
}
}
@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath\Extension;
use Symfony\Component\CssSelector\XPath\XPathExpr;
/**
* XPath expression translator combination extension.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class CombinationExtension extends AbstractExtension
{
public function getCombinationTranslators(): array
{
return [
' ' => $this->translateDescendant(...),
'>' => $this->translateChild(...),
'+' => $this->translateDirectAdjacent(...),
'~' => $this->translateIndirectAdjacent(...),
];
}
public function translateDescendant(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
{
return $xpath->join('/descendant-or-self::*/', $combinedXpath);
}
public function translateChild(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
{
return $xpath->join('/', $combinedXpath);
}
public function translateDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
{
return $xpath
->join('/following-sibling::', $combinedXpath)
->addNameTest()
->addCondition('position() = 1');
}
public function translateIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
{
return $xpath->join('/following-sibling::', $combinedXpath);
}
public function getName(): string
{
return 'combination';
}
}
@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath\Extension;
/**
* XPath expression translator extension interface.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
interface ExtensionInterface
{
/**
* Returns node translators.
*
* These callables will receive the node as first argument and the translator as second argument.
*
* @return callable[]
*/
public function getNodeTranslators(): array;
/**
* Returns combination translators.
*
* @return callable[]
*/
public function getCombinationTranslators(): array;
/**
* Returns function translators.
*
* @return callable[]
*/
public function getFunctionTranslators(): array;
/**
* Returns pseudo-class translators.
*
* @return callable[]
*/
public function getPseudoClassTranslators(): array;
/**
* Returns attribute operation translators.
*
* @return callable[]
*/
public function getAttributeMatchingTranslators(): array;
/**
* Returns extension name.
*/
public function getName(): string;
}
@@ -0,0 +1,165 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath\Extension;
use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
use Symfony\Component\CssSelector\Node\FunctionNode;
use Symfony\Component\CssSelector\Parser\Parser;
use Symfony\Component\CssSelector\XPath\Translator;
use Symfony\Component\CssSelector\XPath\XPathExpr;
/**
* XPath expression translator function extension.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class FunctionExtension extends AbstractExtension
{
public function getFunctionTranslators(): array
{
return [
'nth-child' => $this->translateNthChild(...),
'nth-last-child' => $this->translateNthLastChild(...),
'nth-of-type' => $this->translateNthOfType(...),
'nth-last-of-type' => $this->translateNthLastOfType(...),
'contains' => $this->translateContains(...),
'lang' => $this->translateLang(...),
];
}
/**
* @throws ExpressionErrorException
*/
public function translateNthChild(XPathExpr $xpath, FunctionNode $function, bool $last = false, bool $addNameTest = true): XPathExpr
{
try {
[$a, $b] = Parser::parseSeries($function->getArguments());
} catch (SyntaxErrorException $e) {
throw new ExpressionErrorException(sprintf('Invalid series: "%s".', implode('", "', $function->getArguments())), 0, $e);
}
$xpath->addStarPrefix();
if ($addNameTest) {
$xpath->addNameTest();
}
if (0 === $a) {
return $xpath->addCondition('position() = '.($last ? 'last() - '.($b - 1) : $b));
}
if ($a < 0) {
if ($b < 1) {
return $xpath->addCondition('false()');
}
$sign = '<=';
} else {
$sign = '>=';
}
$expr = 'position()';
if ($last) {
$expr = 'last() - '.$expr;
--$b;
}
if (0 !== $b) {
$expr .= ' - '.$b;
}
$conditions = [sprintf('%s %s 0', $expr, $sign)];
if (1 !== $a && -1 !== $a) {
$conditions[] = sprintf('(%s) mod %d = 0', $expr, $a);
}
return $xpath->addCondition(implode(' and ', $conditions));
// todo: handle an+b, odd, even
// an+b means every-a, plus b, e.g., 2n+1 means odd
// 0n+b means b
// n+0 means a=1, i.e., all elements
// an means every a elements, i.e., 2n means even
// -n means -1n
// -1n+6 means elements 6 and previous
}
public function translateNthLastChild(XPathExpr $xpath, FunctionNode $function): XPathExpr
{
return $this->translateNthChild($xpath, $function, true);
}
public function translateNthOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr
{
return $this->translateNthChild($xpath, $function, false, false);
}
/**
* @throws ExpressionErrorException
*/
public function translateNthLastOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr
{
if ('*' === $xpath->getElement()) {
throw new ExpressionErrorException('"*:nth-of-type()" is not implemented.');
}
return $this->translateNthChild($xpath, $function, true, false);
}
/**
* @throws ExpressionErrorException
*/
public function translateContains(XPathExpr $xpath, FunctionNode $function): XPathExpr
{
$arguments = $function->getArguments();
foreach ($arguments as $token) {
if (!($token->isString() || $token->isIdentifier())) {
throw new ExpressionErrorException('Expected a single string or identifier for :contains(), got '.implode(', ', $arguments));
}
}
return $xpath->addCondition(sprintf(
'contains(string(.), %s)',
Translator::getXpathLiteral($arguments[0]->getValue())
));
}
/**
* @throws ExpressionErrorException
*/
public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr
{
$arguments = $function->getArguments();
foreach ($arguments as $token) {
if (!($token->isString() || $token->isIdentifier())) {
throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments));
}
}
return $xpath->addCondition(sprintf(
'lang(%s)',
Translator::getXpathLiteral($arguments[0]->getValue())
));
}
public function getName(): string
{
return 'function';
}
}
@@ -0,0 +1,178 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath\Extension;
use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
use Symfony\Component\CssSelector\Node\FunctionNode;
use Symfony\Component\CssSelector\XPath\Translator;
use Symfony\Component\CssSelector\XPath\XPathExpr;
/**
* XPath expression translator HTML extension.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class HtmlExtension extends AbstractExtension
{
public function __construct(Translator $translator)
{
$translator
->getExtension('node')
->setFlag(NodeExtension::ELEMENT_NAME_IN_LOWER_CASE, true)
->setFlag(NodeExtension::ATTRIBUTE_NAME_IN_LOWER_CASE, true);
}
public function getPseudoClassTranslators(): array
{
return [
'checked' => $this->translateChecked(...),
'link' => $this->translateLink(...),
'disabled' => $this->translateDisabled(...),
'enabled' => $this->translateEnabled(...),
'selected' => $this->translateSelected(...),
'invalid' => $this->translateInvalid(...),
'hover' => $this->translateHover(...),
'visited' => $this->translateVisited(...),
];
}
public function getFunctionTranslators(): array
{
return [
'lang' => $this->translateLang(...),
];
}
public function translateChecked(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition(
'(@checked '
."and (name(.) = 'input' or name(.) = 'command')"
."and (@type = 'checkbox' or @type = 'radio'))"
);
}
public function translateLink(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition("@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')");
}
public function translateDisabled(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition(
'('
.'@disabled and'
.'('
."(name(.) = 'input' and @type != 'hidden')"
." or name(.) = 'button'"
." or name(.) = 'select'"
." or name(.) = 'textarea'"
." or name(.) = 'command'"
." or name(.) = 'fieldset'"
." or name(.) = 'optgroup'"
." or name(.) = 'option'"
.')'
.') or ('
."(name(.) = 'input' and @type != 'hidden')"
." or name(.) = 'button'"
." or name(.) = 'select'"
." or name(.) = 'textarea'"
.')'
.' and ancestor::fieldset[@disabled]'
);
// todo: in the second half, add "and is not a descendant of that fieldset element's first legend element child, if any."
}
public function translateEnabled(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition(
'('
.'@href and ('
."name(.) = 'a'"
." or name(.) = 'link'"
." or name(.) = 'area'"
.')'
.') or ('
.'('
."name(.) = 'command'"
." or name(.) = 'fieldset'"
." or name(.) = 'optgroup'"
.')'
.' and not(@disabled)'
.') or ('
.'('
."(name(.) = 'input' and @type != 'hidden')"
." or name(.) = 'button'"
." or name(.) = 'select'"
." or name(.) = 'textarea'"
." or name(.) = 'keygen'"
.')'
.' and not (@disabled or ancestor::fieldset[@disabled])'
.') or ('
."name(.) = 'option' and not("
.'@disabled or ancestor::optgroup[@disabled]'
.')'
.')'
);
}
/**
* @throws ExpressionErrorException
*/
public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr
{
$arguments = $function->getArguments();
foreach ($arguments as $token) {
if (!($token->isString() || $token->isIdentifier())) {
throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments));
}
}
return $xpath->addCondition(sprintf(
'ancestor-or-self::*[@lang][1][starts-with(concat('
."translate(@%s, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '-')"
.', %s)]',
'lang',
Translator::getXpathLiteral(strtolower($arguments[0]->getValue()).'-')
));
}
public function translateSelected(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition("(@selected and name(.) = 'option')");
}
public function translateInvalid(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition('0');
}
public function translateHover(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition('0');
}
public function translateVisited(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition('0');
}
public function getName(): string
{
return 'html';
}
}
@@ -0,0 +1,191 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath\Extension;
use Symfony\Component\CssSelector\Node;
use Symfony\Component\CssSelector\XPath\Translator;
use Symfony\Component\CssSelector\XPath\XPathExpr;
/**
* XPath expression translator node extension.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class NodeExtension extends AbstractExtension
{
public const ELEMENT_NAME_IN_LOWER_CASE = 1;
public const ATTRIBUTE_NAME_IN_LOWER_CASE = 2;
public const ATTRIBUTE_VALUE_IN_LOWER_CASE = 4;
private int $flags;
public function __construct(int $flags = 0)
{
$this->flags = $flags;
}
/**
* @return $this
*/
public function setFlag(int $flag, bool $on): static
{
if ($on && !$this->hasFlag($flag)) {
$this->flags += $flag;
}
if (!$on && $this->hasFlag($flag)) {
$this->flags -= $flag;
}
return $this;
}
public function hasFlag(int $flag): bool
{
return (bool) ($this->flags & $flag);
}
public function getNodeTranslators(): array
{
return [
'Selector' => $this->translateSelector(...),
'CombinedSelector' => $this->translateCombinedSelector(...),
'Negation' => $this->translateNegation(...),
'Function' => $this->translateFunction(...),
'Pseudo' => $this->translatePseudo(...),
'Attribute' => $this->translateAttribute(...),
'Class' => $this->translateClass(...),
'Hash' => $this->translateHash(...),
'Element' => $this->translateElement(...),
];
}
public function translateSelector(Node\SelectorNode $node, Translator $translator): XPathExpr
{
return $translator->nodeToXPath($node->getTree());
}
public function translateCombinedSelector(Node\CombinedSelectorNode $node, Translator $translator): XPathExpr
{
return $translator->addCombination($node->getCombinator(), $node->getSelector(), $node->getSubSelector());
}
public function translateNegation(Node\NegationNode $node, Translator $translator): XPathExpr
{
$xpath = $translator->nodeToXPath($node->getSelector());
$subXpath = $translator->nodeToXPath($node->getSubSelector());
$subXpath->addNameTest();
if ($subXpath->getCondition()) {
return $xpath->addCondition(sprintf('not(%s)', $subXpath->getCondition()));
}
return $xpath->addCondition('0');
}
public function translateFunction(Node\FunctionNode $node, Translator $translator): XPathExpr
{
$xpath = $translator->nodeToXPath($node->getSelector());
return $translator->addFunction($xpath, $node);
}
public function translatePseudo(Node\PseudoNode $node, Translator $translator): XPathExpr
{
$xpath = $translator->nodeToXPath($node->getSelector());
return $translator->addPseudoClass($xpath, $node->getIdentifier());
}
public function translateAttribute(Node\AttributeNode $node, Translator $translator): XPathExpr
{
$name = $node->getAttribute();
$safe = $this->isSafeName($name);
if ($this->hasFlag(self::ATTRIBUTE_NAME_IN_LOWER_CASE)) {
$name = strtolower($name);
}
if ($node->getNamespace()) {
$name = sprintf('%s:%s', $node->getNamespace(), $name);
$safe = $safe && $this->isSafeName($node->getNamespace());
}
$attribute = $safe ? '@'.$name : sprintf('attribute::*[name() = %s]', Translator::getXpathLiteral($name));
$value = $node->getValue();
$xpath = $translator->nodeToXPath($node->getSelector());
if ($this->hasFlag(self::ATTRIBUTE_VALUE_IN_LOWER_CASE)) {
$value = strtolower($value);
}
return $translator->addAttributeMatching($xpath, $node->getOperator(), $attribute, $value);
}
public function translateClass(Node\ClassNode $node, Translator $translator): XPathExpr
{
$xpath = $translator->nodeToXPath($node->getSelector());
return $translator->addAttributeMatching($xpath, '~=', '@class', $node->getName());
}
public function translateHash(Node\HashNode $node, Translator $translator): XPathExpr
{
$xpath = $translator->nodeToXPath($node->getSelector());
return $translator->addAttributeMatching($xpath, '=', '@id', $node->getId());
}
public function translateElement(Node\ElementNode $node): XPathExpr
{
$element = $node->getElement();
if ($element && $this->hasFlag(self::ELEMENT_NAME_IN_LOWER_CASE)) {
$element = strtolower($element);
}
if ($element) {
$safe = $this->isSafeName($element);
} else {
$element = '*';
$safe = true;
}
if ($node->getNamespace()) {
$element = sprintf('%s:%s', $node->getNamespace(), $element);
$safe = $safe && $this->isSafeName($node->getNamespace());
}
$xpath = new XPathExpr('', $element);
if (!$safe) {
$xpath->addNameTest();
}
return $xpath;
}
public function getName(): string
{
return 'node';
}
private function isSafeName(string $name): bool
{
return 0 < preg_match('~^[a-zA-Z_][a-zA-Z0-9_.-]*$~', $name);
}
}
@@ -0,0 +1,122 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath\Extension;
use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
use Symfony\Component\CssSelector\XPath\XPathExpr;
/**
* XPath expression translator pseudo-class extension.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class PseudoClassExtension extends AbstractExtension
{
public function getPseudoClassTranslators(): array
{
return [
'root' => $this->translateRoot(...),
'scope' => $this->translateScopePseudo(...),
'first-child' => $this->translateFirstChild(...),
'last-child' => $this->translateLastChild(...),
'first-of-type' => $this->translateFirstOfType(...),
'last-of-type' => $this->translateLastOfType(...),
'only-child' => $this->translateOnlyChild(...),
'only-of-type' => $this->translateOnlyOfType(...),
'empty' => $this->translateEmpty(...),
];
}
public function translateRoot(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition('not(parent::*)');
}
public function translateScopePseudo(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition('1');
}
public function translateFirstChild(XPathExpr $xpath): XPathExpr
{
return $xpath
->addStarPrefix()
->addNameTest()
->addCondition('position() = 1');
}
public function translateLastChild(XPathExpr $xpath): XPathExpr
{
return $xpath
->addStarPrefix()
->addNameTest()
->addCondition('position() = last()');
}
/**
* @throws ExpressionErrorException
*/
public function translateFirstOfType(XPathExpr $xpath): XPathExpr
{
if ('*' === $xpath->getElement()) {
throw new ExpressionErrorException('"*:first-of-type" is not implemented.');
}
return $xpath
->addStarPrefix()
->addCondition('position() = 1');
}
/**
* @throws ExpressionErrorException
*/
public function translateLastOfType(XPathExpr $xpath): XPathExpr
{
if ('*' === $xpath->getElement()) {
throw new ExpressionErrorException('"*:last-of-type" is not implemented.');
}
return $xpath
->addStarPrefix()
->addCondition('position() = last()');
}
public function translateOnlyChild(XPathExpr $xpath): XPathExpr
{
return $xpath
->addStarPrefix()
->addNameTest()
->addCondition('last() = 1');
}
public function translateOnlyOfType(XPathExpr $xpath): XPathExpr
{
$element = $xpath->getElement();
return $xpath->addCondition(sprintf('count(preceding-sibling::%s)=0 and count(following-sibling::%s)=0', $element, $element));
}
public function translateEmpty(XPathExpr $xpath): XPathExpr
{
return $xpath->addCondition('not(*) and not(string-length())');
}
public function getName(): string
{
return 'pseudo-class';
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath;
use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
use Symfony\Component\CssSelector\Node\FunctionNode;
use Symfony\Component\CssSelector\Node\NodeInterface;
use Symfony\Component\CssSelector\Node\SelectorNode;
use Symfony\Component\CssSelector\Parser\Parser;
use Symfony\Component\CssSelector\Parser\ParserInterface;
/**
* XPath expression translator interface.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class Translator implements TranslatorInterface
{
private ParserInterface $mainParser;
/**
* @var ParserInterface[]
*/
private array $shortcutParsers = [];
/**
* @var Extension\ExtensionInterface[]
*/
private array $extensions = [];
private array $nodeTranslators = [];
private array $combinationTranslators = [];
private array $functionTranslators = [];
private array $pseudoClassTranslators = [];
private array $attributeMatchingTranslators = [];
public function __construct(ParserInterface $parser = null)
{
$this->mainParser = $parser ?? new Parser();
$this
->registerExtension(new Extension\NodeExtension())
->registerExtension(new Extension\CombinationExtension())
->registerExtension(new Extension\FunctionExtension())
->registerExtension(new Extension\PseudoClassExtension())
->registerExtension(new Extension\AttributeMatchingExtension())
;
}
public static function getXpathLiteral(string $element): string
{
if (!str_contains($element, "'")) {
return "'".$element."'";
}
if (!str_contains($element, '"')) {
return '"'.$element.'"';
}
$string = $element;
$parts = [];
while (true) {
if (false !== $pos = strpos($string, "'")) {
$parts[] = sprintf("'%s'", substr($string, 0, $pos));
$parts[] = "\"'\"";
$string = substr($string, $pos + 1);
} else {
$parts[] = "'$string'";
break;
}
}
return sprintf('concat(%s)', implode(', ', $parts));
}
public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string
{
$selectors = $this->parseSelectors($cssExpr);
/** @var SelectorNode $selector */
foreach ($selectors as $index => $selector) {
if (null !== $selector->getPseudoElement()) {
throw new ExpressionErrorException('Pseudo-elements are not supported.');
}
$selectors[$index] = $this->selectorToXPath($selector, $prefix);
}
return implode(' | ', $selectors);
}
public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string
{
return ($prefix ?: '').$this->nodeToXPath($selector);
}
/**
* @return $this
*/
public function registerExtension(Extension\ExtensionInterface $extension): static
{
$this->extensions[$extension->getName()] = $extension;
$this->nodeTranslators = array_merge($this->nodeTranslators, $extension->getNodeTranslators());
$this->combinationTranslators = array_merge($this->combinationTranslators, $extension->getCombinationTranslators());
$this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators());
$this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators());
$this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators());
return $this;
}
/**
* @throws ExpressionErrorException
*/
public function getExtension(string $name): Extension\ExtensionInterface
{
if (!isset($this->extensions[$name])) {
throw new ExpressionErrorException(sprintf('Extension "%s" not registered.', $name));
}
return $this->extensions[$name];
}
/**
* @return $this
*/
public function registerParserShortcut(ParserInterface $shortcut): static
{
$this->shortcutParsers[] = $shortcut;
return $this;
}
/**
* @throws ExpressionErrorException
*/
public function nodeToXPath(NodeInterface $node): XPathExpr
{
if (!isset($this->nodeTranslators[$node->getNodeName()])) {
throw new ExpressionErrorException(sprintf('Node "%s" not supported.', $node->getNodeName()));
}
return $this->nodeTranslators[$node->getNodeName()]($node, $this);
}
/**
* @throws ExpressionErrorException
*/
public function addCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr
{
if (!isset($this->combinationTranslators[$combiner])) {
throw new ExpressionErrorException(sprintf('Combiner "%s" not supported.', $combiner));
}
return $this->combinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath));
}
/**
* @throws ExpressionErrorException
*/
public function addFunction(XPathExpr $xpath, FunctionNode $function): XPathExpr
{
if (!isset($this->functionTranslators[$function->getName()])) {
throw new ExpressionErrorException(sprintf('Function "%s" not supported.', $function->getName()));
}
return $this->functionTranslators[$function->getName()]($xpath, $function);
}
/**
* @throws ExpressionErrorException
*/
public function addPseudoClass(XPathExpr $xpath, string $pseudoClass): XPathExpr
{
if (!isset($this->pseudoClassTranslators[$pseudoClass])) {
throw new ExpressionErrorException(sprintf('Pseudo-class "%s" not supported.', $pseudoClass));
}
return $this->pseudoClassTranslators[$pseudoClass]($xpath);
}
/**
* @throws ExpressionErrorException
*/
public function addAttributeMatching(XPathExpr $xpath, string $operator, string $attribute, ?string $value): XPathExpr
{
if (!isset($this->attributeMatchingTranslators[$operator])) {
throw new ExpressionErrorException(sprintf('Attribute matcher operator "%s" not supported.', $operator));
}
return $this->attributeMatchingTranslators[$operator]($xpath, $attribute, $value);
}
/**
* @return SelectorNode[]
*/
private function parseSelectors(string $css): array
{
foreach ($this->shortcutParsers as $shortcut) {
$tokens = $shortcut->parse($css);
if ($tokens) {
return $tokens;
}
}
return $this->mainParser->parse($css);
}
}
@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath;
use Symfony\Component\CssSelector\Node\SelectorNode;
/**
* XPath expression translator interface.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
interface TranslatorInterface
{
/**
* Translates a CSS selector to an XPath expression.
*/
public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string;
/**
* Translates a parsed selector node to an XPath expression.
*/
public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string;
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\CssSelector\XPath;
/**
* XPath expression translator interface.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*
* @internal
*/
class XPathExpr
{
private string $path;
private string $element;
private string $condition;
public function __construct(string $path = '', string $element = '*', string $condition = '', bool $starPrefix = false)
{
$this->path = $path;
$this->element = $element;
$this->condition = $condition;
if ($starPrefix) {
$this->addStarPrefix();
}
}
public function getElement(): string
{
return $this->element;
}
/**
* @return $this
*/
public function addCondition(string $condition): static
{
$this->condition = $this->condition ? sprintf('(%s) and (%s)', $this->condition, $condition) : $condition;
return $this;
}
public function getCondition(): string
{
return $this->condition;
}
/**
* @return $this
*/
public function addNameTest(): static
{
if ('*' !== $this->element) {
$this->addCondition('name() = '.Translator::getXpathLiteral($this->element));
$this->element = '*';
}
return $this;
}
/**
* @return $this
*/
public function addStarPrefix(): static
{
$this->path .= '*/';
return $this;
}
/**
* Joins another XPathExpr with a combiner.
*
* @return $this
*/
public function join(string $combiner, self $expr): static
{
$path = $this->__toString().$combiner;
if ('*/' !== $expr->path) {
$path .= $expr->path;
}
$this->path = $path;
$this->element = $expr->element;
$this->condition = $expr->condition;
return $this;
}
public function __toString(): string
{
$path = $this->path.$this->element;
$condition = null === $this->condition || '' === $this->condition ? '' : '['.$this->condition.']';
return $path.$condition;
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "symfony/css-selector",
"type": "library",
"description": "Converts CSS selectors to XPath expressions",
"keywords": [],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Jean-François Simon",
"email": "jeanfrancois.simon@sensiolabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.1"
},
"autoload": {
"psr-4": { "Symfony\\Component\\CssSelector\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}
@@ -0,0 +1,5 @@
CHANGELOG
=========
The changelog is maintained for all Symfony contracts at the following URL:
https://github.com/symfony/contracts/blob/main/CHANGELOG.md
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2020-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+26
View File
@@ -0,0 +1,26 @@
Symfony Deprecation Contracts
=============================
A generic function and convention to trigger deprecation notices.
This package provides a single global function named `trigger_deprecation()` that triggers silenced deprecation notices.
By using a custom PHP error handler such as the one provided by the Symfony ErrorHandler component,
the triggered deprecations can be caught and logged for later discovery, both on dev and prod environments.
The function requires at least 3 arguments:
- the name of the Composer package that is triggering the deprecation
- the version of the package that introduced the deprecation
- the message of the deprecation
- more arguments can be provided: they will be inserted in the message using `printf()` formatting
Example:
```php
trigger_deprecation('symfony/blockchain', '8.9', 'Using "%s" is deprecated, use "%s" instead.', 'bitcoin', 'fabcoin');
```
This will generate the following message:
`Since symfony/blockchain 8.9: Using "bitcoin" is deprecated, use "fabcoin" instead.`
While not recommended, the deprecation notices can be completely ignored by declaring an empty
`function trigger_deprecation() {}` in your application.
@@ -0,0 +1,35 @@
{
"name": "symfony/deprecation-contracts",
"type": "library",
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.1"
},
"autoload": {
"files": [
"function.php"
]
},
"minimum-stability": "dev",
"extra": {
"branch-alias": {
"dev-main": "3.4-dev"
},
"thanks": {
"name": "symfony/contracts",
"url": "https://github.com/symfony/contracts"
}
}
}
@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
if (!function_exists('trigger_deprecation')) {
/**
* Triggers a silenced deprecation notice.
*
* @param string $package The name of the Composer package that is triggering the deprecation
* @param string $version The version of the package that introduced the deprecation
* @param string $message The message of the deprecation
* @param mixed ...$args Values to insert in the message using printf() formatting
*
* @author Nicolas Grekas <p@tchwork.com>
*/
function trigger_deprecation(string $package, string $version, string $message, mixed ...$args): void
{
@trigger_error(($package || $version ? "Since $package $version: " : '').($args ? vsprintf($message, $args) : $message), \E_USER_DEPRECATED);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler;
/**
* Any HTML element that can link to an URI.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
abstract class AbstractUriElement
{
/**
* @var \DOMElement
*/
protected $node;
/**
* @var string|null The method to use for the element
*/
protected $method;
/**
* @var string The URI of the page where the element is embedded (or the base href)
*/
protected $currentUri;
/**
* @param \DOMElement $node A \DOMElement instance
* @param string|null $currentUri The URI of the page where the link is embedded (or the base href)
* @param string|null $method The method to use for the link (GET by default)
*
* @throws \InvalidArgumentException if the node is not a link
*/
public function __construct(\DOMElement $node, string $currentUri = null, ?string $method = 'GET')
{
$this->setNode($node);
$this->method = $method ? strtoupper($method) : null;
$this->currentUri = $currentUri;
$elementUriIsRelative = null === parse_url(trim($this->getRawUri()), \PHP_URL_SCHEME);
$baseUriIsAbsolute = null !== $this->currentUri && \in_array(strtolower(substr($this->currentUri, 0, 4)), ['http', 'file']);
if ($elementUriIsRelative && !$baseUriIsAbsolute) {
throw new \InvalidArgumentException(sprintf('The URL of the element is relative, so you must define its base URI passing an absolute URL to the constructor of the "%s" class ("%s" was passed).', __CLASS__, $this->currentUri));
}
}
/**
* Gets the node associated with this link.
*/
public function getNode(): \DOMElement
{
return $this->node;
}
/**
* Gets the method associated with this link.
*/
public function getMethod(): string
{
return $this->method ?? 'GET';
}
/**
* Gets the URI associated with this link.
*/
public function getUri(): string
{
return UriResolver::resolve($this->getRawUri(), $this->currentUri);
}
/**
* Returns raw URI data.
*/
abstract protected function getRawUri(): string;
/**
* Returns the canonicalized URI path (see RFC 3986, section 5.2.4).
*
* @param string $path URI path
*/
protected function canonicalizePath(string $path): string
{
if ('' === $path || '/' === $path) {
return $path;
}
if (str_ends_with($path, '.')) {
$path .= '/';
}
$output = [];
foreach (explode('/', $path) as $segment) {
if ('..' === $segment) {
array_pop($output);
} elseif ('.' !== $segment) {
$output[] = $segment;
}
}
return implode('/', $output);
}
/**
* Sets current \DOMElement instance.
*
* @param \DOMElement $node A \DOMElement instance
*
* @return void
*
* @throws \LogicException If given node is not an anchor
*/
abstract protected function setNode(\DOMElement $node);
}
+124
View File
@@ -0,0 +1,124 @@
CHANGELOG
=========
6.4
---
* Add `CrawlerAnySelectorTextContains` test constraint
* Add `CrawlerAnySelectorTextSame` test constraint
* Add argument `$default` to `Crawler::attr()`
6.3
---
* Add `$useHtml5Parser` argument to `Crawler`
* Add `CrawlerSelectorCount` test constraint
* Add argument `$normalizeWhitespace` to `Crawler::innerText()`
* Make `Crawler::innerText()` return the first non-empty text
6.0
---
* Remove `Crawler::parents()` method, use `ancestors()` instead
5.4
---
* Add `Crawler::innerText` method.
5.3
---
* The `parents()` method is deprecated. Use `ancestors()` instead.
* Marked the `containsOption()`, `availableOptionValues()`, and `disableValidation()` methods of the
`ChoiceFormField` class as internal
5.1.0
-----
* Added an internal cache layer on top of the CssSelectorConverter
* Added `UriResolver` to resolve an URI according to a base URI
5.0.0
-----
* Added argument `$selector` to `Crawler::children()`
* Added argument `$default` to `Crawler::text()` and `html()`
4.4.0
-----
* Added `Form::getName()` method.
* Added `Crawler::matches()` method.
* Added `Crawler::closest()` method.
* Added `Crawler::outerHtml()` method.
* Added an argument to the `Crawler::text()` method to opt-in normalizing whitespaces.
4.3.0
-----
* Added PHPUnit constraints: `CrawlerSelectorAttributeValueSame`, `CrawlerSelectorExists`, `CrawlerSelectorTextContains`
and `CrawlerSelectorTextSame`
* Added return of element name (`_name`) in `extract()` method.
* Added ability to return a default value in `text()` and `html()` instead of throwing an exception when node is empty.
* When available, the [html5-php library](https://github.com/Masterminds/html5-php) is used to
parse HTML added to a Crawler for better support of HTML5 tags.
4.2.0
-----
* The `$currentUri` constructor argument of the `AbstractUriElement`, `Link` and
`Image` classes is now optional.
* The `Crawler::children()` method will have a new `$selector` argument in version 5.0,
not defining it is deprecated.
3.1.0
-----
* All the URI parsing logic have been abstracted in the `AbstractUriElement` class.
The `Link` class is now a child of `AbstractUriElement`.
* Added an `Image` class to crawl images and parse their `src` attribute,
and `selectImage`, `image`, `images` methods in the `Crawler` (the image version of the equivalent `link` methods).
2.5.0
-----
* [BC BREAK] The default value for checkbox and radio inputs without a value attribute have changed
from '1' to 'on' to match the HTML specification.
* [BC BREAK] The typehints on the `Link`, `Form` and `FormField` classes have been changed from
`\DOMNode` to `DOMElement`. Using any other type of `DOMNode` was triggering fatal errors in previous
versions. Code extending these classes will need to update the typehints when overwriting these methods.
2.4.0
-----
* `Crawler::addXmlContent()` removes the default document namespace again if it's an only namespace.
* added support for automatic discovery and explicit registration of document
namespaces for `Crawler::filterXPath()` and `Crawler::filter()`
* improved content type guessing in `Crawler::addContent()`
* [BC BREAK] `Crawler::addXmlContent()` no longer removes the default document
namespace
2.3.0
-----
* added Crawler::html()
* [BC BREAK] Crawler::each() and Crawler::reduce() now return Crawler instances instead of DomElement instances
* added schema relative URL support to links
* added support for HTML5 'form' attribute
2.2.0
-----
* added a way to set raw path to the file in FileFormField - necessary for
simulating HTTP requests
2.1.0
-----
* added support for the HTTP PATCH method
* refactored the Form class internals to support multi-dimensional fields
(the public API is backward compatible)
* added a way to get parsing errors for Crawler::addHtmlContent() and
Crawler::addXmlContent() via libxml functions
* added support for submitting a form without a submit button
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Field;
/**
* ChoiceFormField represents a choice form field.
*
* It is constructed from an HTML select tag, or an HTML checkbox, or radio inputs.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class ChoiceFormField extends FormField
{
private string $type;
private bool $multiple;
private array $options;
private bool $validationDisabled = false;
/**
* Returns true if the field should be included in the submitted values.
*
* @return bool true if the field should be included in the submitted values, false otherwise
*/
public function hasValue(): bool
{
// don't send a value for unchecked checkboxes
if (\in_array($this->type, ['checkbox', 'radio']) && null === $this->value) {
return false;
}
return true;
}
/**
* Check if the current selected option is disabled.
*/
public function isDisabled(): bool
{
if (parent::isDisabled() && 'select' === $this->type) {
return true;
}
foreach ($this->options as $option) {
if ($option['value'] == $this->value && $option['disabled']) {
return true;
}
}
return false;
}
/**
* Sets the value of the field.
*
* @return void
*/
public function select(string|array|bool $value)
{
$this->setValue($value);
}
/**
* Ticks a checkbox.
*
* @return void
*
* @throws \LogicException When the type provided is not correct
*/
public function tick()
{
if ('checkbox' !== $this->type) {
throw new \LogicException(sprintf('You cannot tick "%s" as it is not a checkbox (%s).', $this->name, $this->type));
}
$this->setValue(true);
}
/**
* Unticks a checkbox.
*
* @return void
*
* @throws \LogicException When the type provided is not correct
*/
public function untick()
{
if ('checkbox' !== $this->type) {
throw new \LogicException(sprintf('You cannot untick "%s" as it is not a checkbox (%s).', $this->name, $this->type));
}
$this->setValue(false);
}
/**
* Sets the value of the field.
*
* @return void
*
* @throws \InvalidArgumentException When value type provided is not correct
*/
public function setValue(string|array|bool|null $value)
{
if ('checkbox' === $this->type && false === $value) {
// uncheck
$this->value = null;
} elseif ('checkbox' === $this->type && true === $value) {
// check
$this->value = $this->options[0]['value'];
} else {
if (\is_array($value)) {
if (!$this->multiple) {
throw new \InvalidArgumentException(sprintf('The value for "%s" cannot be an array.', $this->name));
}
foreach ($value as $v) {
if (!$this->containsOption($v, $this->options)) {
throw new \InvalidArgumentException(sprintf('Input "%s" cannot take "%s" as a value (possible values: "%s").', $this->name, $v, implode('", "', $this->availableOptionValues())));
}
}
} elseif (!$this->containsOption($value, $this->options)) {
throw new \InvalidArgumentException(sprintf('Input "%s" cannot take "%s" as a value (possible values: "%s").', $this->name, $value, implode('", "', $this->availableOptionValues())));
}
if ($this->multiple) {
$value = (array) $value;
}
if (\is_array($value)) {
$this->value = $value;
} else {
parent::setValue($value);
}
}
}
/**
* Adds a choice to the current ones.
*
* @throws \LogicException When choice provided is not multiple nor radio
*
* @internal
*/
public function addChoice(\DOMElement $node): void
{
if (!$this->multiple && 'radio' !== $this->type) {
throw new \LogicException(sprintf('Unable to add a choice for "%s" as it is not multiple or is not a radio button.', $this->name));
}
$option = $this->buildOptionValue($node);
$this->options[] = $option;
if ($node->hasAttribute('checked')) {
$this->value = $option['value'];
}
}
/**
* Returns the type of the choice field (radio, select, or checkbox).
*/
public function getType(): string
{
return $this->type;
}
/**
* Returns true if the field accepts multiple values.
*/
public function isMultiple(): bool
{
return $this->multiple;
}
/**
* Initializes the form field.
*
* @return void
*
* @throws \LogicException When node type is incorrect
*/
protected function initialize()
{
if ('input' !== $this->node->nodeName && 'select' !== $this->node->nodeName) {
throw new \LogicException(sprintf('A ChoiceFormField can only be created from an input or select tag (%s given).', $this->node->nodeName));
}
if ('input' === $this->node->nodeName && 'checkbox' !== strtolower($this->node->getAttribute('type')) && 'radio' !== strtolower($this->node->getAttribute('type'))) {
throw new \LogicException(sprintf('A ChoiceFormField can only be created from an input tag with a type of checkbox or radio (given type is "%s").', $this->node->getAttribute('type')));
}
$this->value = null;
$this->options = [];
$this->multiple = false;
if ('input' == $this->node->nodeName) {
$this->type = strtolower($this->node->getAttribute('type'));
$optionValue = $this->buildOptionValue($this->node);
$this->options[] = $optionValue;
if ($this->node->hasAttribute('checked')) {
$this->value = $optionValue['value'];
}
} else {
$this->type = 'select';
if ($this->node->hasAttribute('multiple')) {
$this->multiple = true;
$this->value = [];
$this->name = str_replace('[]', '', $this->name);
}
$found = false;
foreach ($this->xpath->query('descendant::option', $this->node) as $option) {
$optionValue = $this->buildOptionValue($option);
$this->options[] = $optionValue;
if ($option->hasAttribute('selected')) {
$found = true;
if ($this->multiple) {
$this->value[] = $optionValue['value'];
} else {
$this->value = $optionValue['value'];
}
}
}
// if no option is selected and if it is a simple select box, take the first option as the value
if (!$found && !$this->multiple && $this->options) {
$this->value = $this->options[0]['value'];
}
}
}
/**
* Returns option value with associated disabled flag.
*/
private function buildOptionValue(\DOMElement $node): array
{
$option = [];
$defaultDefaultValue = 'select' === $this->node->nodeName ? '' : 'on';
$defaultValue = (isset($node->nodeValue) && !empty($node->nodeValue)) ? $node->nodeValue : $defaultDefaultValue;
$option['value'] = $node->hasAttribute('value') ? $node->getAttribute('value') : $defaultValue;
$option['disabled'] = $node->hasAttribute('disabled');
return $option;
}
/**
* Checks whether given value is in the existing options.
*
* @internal
*/
public function containsOption(string $optionValue, array $options): bool
{
if ($this->validationDisabled) {
return true;
}
foreach ($options as $option) {
if ($option['value'] == $optionValue) {
return true;
}
}
return false;
}
/**
* Returns list of available field options.
*
* @internal
*/
public function availableOptionValues(): array
{
$values = [];
foreach ($this->options as $option) {
$values[] = $option['value'];
}
return $values;
}
/**
* Disables the internal validation of the field.
*
* @internal
*
* @return $this
*/
public function disableValidation(): static
{
$this->validationDisabled = true;
return $this;
}
}
@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Field;
/**
* FileFormField represents a file form field (an HTML file input tag).
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class FileFormField extends FormField
{
/**
* Sets the PHP error code associated with the field.
*
* @param int $error The error code (one of UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_PARTIAL, UPLOAD_ERR_NO_FILE, UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, or UPLOAD_ERR_EXTENSION)
*
* @return void
*
* @throws \InvalidArgumentException When error code doesn't exist
*/
public function setErrorCode(int $error)
{
$codes = [\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE, \UPLOAD_ERR_PARTIAL, \UPLOAD_ERR_NO_FILE, \UPLOAD_ERR_NO_TMP_DIR, \UPLOAD_ERR_CANT_WRITE, \UPLOAD_ERR_EXTENSION];
if (!\in_array($error, $codes)) {
throw new \InvalidArgumentException(sprintf('The error code "%s" is not valid.', $error));
}
$this->value = ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => $error, 'size' => 0];
}
/**
* Sets the value of the field.
*
* @return void
*/
public function upload(?string $value)
{
$this->setValue($value);
}
/**
* Sets the value of the field.
*
* @return void
*/
public function setValue(?string $value)
{
if (null !== $value && is_readable($value)) {
$error = \UPLOAD_ERR_OK;
$size = filesize($value);
$info = pathinfo($value);
$name = $info['basename'];
// copy to a tmp location
$tmp = sys_get_temp_dir().'/'.strtr(substr(base64_encode(hash('sha256', uniqid(mt_rand(), true), true)), 0, 7), '/', '_');
if (\array_key_exists('extension', $info)) {
$tmp .= '.'.$info['extension'];
}
if (is_file($tmp)) {
unlink($tmp);
}
copy($value, $tmp);
$value = $tmp;
} else {
$error = \UPLOAD_ERR_NO_FILE;
$size = 0;
$name = '';
$value = '';
}
$this->value = ['name' => $name, 'type' => '', 'tmp_name' => $value, 'error' => $error, 'size' => $size];
}
/**
* Sets path to the file as string for simulating HTTP request.
*
* @return void
*/
public function setFilePath(string $path)
{
parent::setValue($path);
}
/**
* Initializes the form field.
*
* @return void
*
* @throws \LogicException When node type is incorrect
*/
protected function initialize()
{
if ('input' !== $this->node->nodeName) {
throw new \LogicException(sprintf('A FileFormField can only be created from an input tag (%s given).', $this->node->nodeName));
}
if ('file' !== strtolower($this->node->getAttribute('type'))) {
throw new \LogicException(sprintf('A FileFormField can only be created from an input tag with a type of file (given type is "%s").', $this->node->getAttribute('type')));
}
$this->setValue(null);
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Field;
/**
* FormField is the abstract class for all form fields.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
abstract class FormField
{
/**
* @var \DOMElement
*/
protected $node;
/**
* @var string
*/
protected $name;
/**
* @var string
*/
protected $value;
/**
* @var \DOMDocument
*/
protected $document;
/**
* @var \DOMXPath
*/
protected $xpath;
/**
* @var bool
*/
protected $disabled;
/**
* @param \DOMElement $node The node associated with this field
*/
public function __construct(\DOMElement $node)
{
$this->node = $node;
$this->name = $node->getAttribute('name');
$this->xpath = new \DOMXPath($node->ownerDocument);
$this->initialize();
}
/**
* Returns the label tag associated to the field or null if none.
*/
public function getLabel(): ?\DOMElement
{
$xpath = new \DOMXPath($this->node->ownerDocument);
if ($this->node->hasAttribute('id')) {
$labels = $xpath->query(sprintf('descendant::label[@for="%s"]', $this->node->getAttribute('id')));
if ($labels->length > 0) {
return $labels->item(0);
}
}
$labels = $xpath->query('ancestor::label[1]', $this->node);
return $labels->length > 0 ? $labels->item(0) : null;
}
/**
* Returns the name of the field.
*/
public function getName(): string
{
return $this->name;
}
/**
* Gets the value of the field.
*/
public function getValue(): string|array|null
{
return $this->value;
}
/**
* Sets the value of the field.
*
* @return void
*/
public function setValue(?string $value)
{
$this->value = $value ?? '';
}
/**
* Returns true if the field should be included in the submitted values.
*/
public function hasValue(): bool
{
return true;
}
/**
* Check if the current field is disabled.
*/
public function isDisabled(): bool
{
return $this->node->hasAttribute('disabled');
}
/**
* Initializes the form field.
*
* @return void
*/
abstract protected function initialize();
}
@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Field;
/**
* InputFormField represents an input form field (an HTML input tag).
*
* For inputs with type of file, checkbox, or radio, there are other more
* specialized classes (cf. FileFormField and ChoiceFormField).
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class InputFormField extends FormField
{
/**
* Initializes the form field.
*
* @return void
*
* @throws \LogicException When node type is incorrect
*/
protected function initialize()
{
if ('input' !== $this->node->nodeName && 'button' !== $this->node->nodeName) {
throw new \LogicException(sprintf('An InputFormField can only be created from an input or button tag (%s given).', $this->node->nodeName));
}
$type = strtolower($this->node->getAttribute('type'));
if ('checkbox' === $type) {
throw new \LogicException('Checkboxes should be instances of ChoiceFormField.');
}
if ('file' === $type) {
throw new \LogicException('File inputs should be instances of FileFormField.');
}
$this->value = $this->node->getAttribute('value');
}
}
@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Field;
/**
* TextareaFormField represents a textarea form field (an HTML textarea tag).
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class TextareaFormField extends FormField
{
/**
* Initializes the form field.
*
* @return void
*
* @throws \LogicException When node type is incorrect
*/
protected function initialize()
{
if ('textarea' !== $this->node->nodeName) {
throw new \LogicException(sprintf('A TextareaFormField can only be created from a textarea tag (%s given).', $this->node->nodeName));
}
$this->value = '';
foreach ($this->node->childNodes as $node) {
$this->value .= $node->wholeText;
}
}
}
+470
View File
@@ -0,0 +1,470 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
use Symfony\Component\DomCrawler\Field\FormField;
/**
* Form represents an HTML form.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class Form extends Link implements \ArrayAccess
{
private \DOMElement $button;
private FormFieldRegistry $fields;
private ?string $baseHref;
/**
* @param \DOMElement $node A \DOMElement instance
* @param string|null $currentUri The URI of the page where the form is embedded
* @param string|null $method The method to use for the link (if null, it defaults to the method defined by the form)
* @param string|null $baseHref The URI of the <base> used for relative links, but not for empty action
*
* @throws \LogicException if the node is not a button inside a form tag
*/
public function __construct(\DOMElement $node, string $currentUri = null, string $method = null, string $baseHref = null)
{
parent::__construct($node, $currentUri, $method);
$this->baseHref = $baseHref;
$this->initialize();
}
/**
* Gets the form node associated with this form.
*/
public function getFormNode(): \DOMElement
{
return $this->node;
}
/**
* Sets the value of the fields.
*
* @param array $values An array of field values
*
* @return $this
*/
public function setValues(array $values): static
{
foreach ($values as $name => $value) {
$this->fields->set($name, $value);
}
return $this;
}
/**
* Gets the field values.
*
* The returned array does not include file fields (@see getFiles).
*/
public function getValues(): array
{
$values = [];
foreach ($this->fields->all() as $name => $field) {
if ($field->isDisabled()) {
continue;
}
if (!$field instanceof Field\FileFormField && $field->hasValue()) {
$values[$name] = $field->getValue();
}
}
return $values;
}
/**
* Gets the file field values.
*/
public function getFiles(): array
{
if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) {
return [];
}
$files = [];
foreach ($this->fields->all() as $name => $field) {
if ($field->isDisabled()) {
continue;
}
if ($field instanceof Field\FileFormField) {
$files[$name] = $field->getValue();
}
}
return $files;
}
/**
* Gets the field values as PHP.
*
* This method converts fields with the array notation
* (like foo[bar] to arrays) like PHP does.
*/
public function getPhpValues(): array
{
$values = [];
foreach ($this->getValues() as $name => $value) {
$qs = http_build_query([$name => $value], '', '&');
if (!empty($qs)) {
parse_str($qs, $expandedValue);
$varName = substr($name, 0, \strlen(key($expandedValue)));
$values[] = [$varName => current($expandedValue)];
}
}
return array_replace_recursive([], ...$values);
}
/**
* Gets the file field values as PHP.
*
* This method converts fields with the array notation
* (like foo[bar] to arrays) like PHP does.
* The returned array is consistent with the array for field values
* (@see getPhpValues), rather than uploaded files found in $_FILES.
* For a compound file field foo[bar] it will create foo[bar][name],
* instead of foo[name][bar] which would be found in $_FILES.
*/
public function getPhpFiles(): array
{
$values = [];
foreach ($this->getFiles() as $name => $value) {
$qs = http_build_query([$name => $value], '', '&');
if (!empty($qs)) {
parse_str($qs, $expandedValue);
$varName = substr($name, 0, \strlen(key($expandedValue)));
array_walk_recursive(
$expandedValue,
function (&$value, $key) {
if (ctype_digit($value) && ('size' === $key || 'error' === $key)) {
$value = (int) $value;
}
}
);
reset($expandedValue);
$values[] = [$varName => current($expandedValue)];
}
}
return array_replace_recursive([], ...$values);
}
/**
* Gets the URI of the form.
*
* The returned URI is not the same as the form "action" attribute.
* This method merges the value if the method is GET to mimics
* browser behavior.
*/
public function getUri(): string
{
$uri = parent::getUri();
if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) {
$query = parse_url($uri, \PHP_URL_QUERY);
$currentParameters = [];
if ($query) {
parse_str($query, $currentParameters);
}
$queryString = http_build_query(array_merge($currentParameters, $this->getValues()), '', '&');
$pos = strpos($uri, '?');
$base = false === $pos ? $uri : substr($uri, 0, $pos);
$uri = rtrim($base.'?'.$queryString, '?');
}
return $uri;
}
protected function getRawUri(): string
{
// If the form was created from a button rather than the form node, check for HTML5 action overrides
if ($this->button !== $this->node && $this->button->getAttribute('formaction')) {
return $this->button->getAttribute('formaction');
}
return $this->node->getAttribute('action');
}
/**
* Gets the form method.
*
* If no method is defined in the form, GET is returned.
*/
public function getMethod(): string
{
if (null !== $this->method) {
return $this->method;
}
// If the form was created from a button rather than the form node, check for HTML5 method override
if ($this->button !== $this->node && $this->button->getAttribute('formmethod')) {
return strtoupper($this->button->getAttribute('formmethod'));
}
return $this->node->getAttribute('method') ? strtoupper($this->node->getAttribute('method')) : 'GET';
}
/**
* Gets the form name.
*
* If no name is defined on the form, an empty string is returned.
*/
public function getName(): string
{
return $this->node->getAttribute('name');
}
/**
* Returns true if the named field exists.
*/
public function has(string $name): bool
{
return $this->fields->has($name);
}
/**
* Removes a field from the form.
*
* @return void
*/
public function remove(string $name)
{
$this->fields->remove($name);
}
/**
* Gets a named field.
*
* @return FormField|FormField[]|FormField[][]
*
* @throws \InvalidArgumentException When field is not present in this form
*/
public function get(string $name): FormField|array
{
return $this->fields->get($name);
}
/**
* Sets a named field.
*
* @return void
*/
public function set(FormField $field)
{
$this->fields->add($field);
}
/**
* Gets all fields.
*
* @return FormField[]
*/
public function all(): array
{
return $this->fields->all();
}
/**
* Returns true if the named field exists.
*
* @param string $name The field name
*/
public function offsetExists(mixed $name): bool
{
return $this->has($name);
}
/**
* Gets the value of a field.
*
* @param string $name The field name
*
* @return FormField|FormField[]|FormField[][]
*
* @throws \InvalidArgumentException if the field does not exist
*/
public function offsetGet(mixed $name): FormField|array
{
return $this->fields->get($name);
}
/**
* Sets the value of a field.
*
* @param string $name The field name
* @param string|array $value The value of the field
*
* @throws \InvalidArgumentException if the field does not exist
*/
public function offsetSet(mixed $name, mixed $value): void
{
$this->fields->set($name, $value);
}
/**
* Removes a field from the form.
*
* @param string $name The field name
*/
public function offsetUnset(mixed $name): void
{
$this->fields->remove($name);
}
/**
* Disables validation.
*
* @return $this
*/
public function disableValidation(): static
{
foreach ($this->fields->all() as $field) {
if ($field instanceof Field\ChoiceFormField) {
$field->disableValidation();
}
}
return $this;
}
/**
* Sets the node for the form.
*
* Expects a 'submit' button \DOMElement and finds the corresponding form element, or the form element itself.
*
* @return void
*
* @throws \LogicException If given node is not a button or input or does not have a form ancestor
*/
protected function setNode(\DOMElement $node)
{
$this->button = $node;
if ('button' === $node->nodeName || ('input' === $node->nodeName && \in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image']))) {
if ($node->hasAttribute('form')) {
// if the node has the HTML5-compliant 'form' attribute, use it
$formId = $node->getAttribute('form');
$form = $node->ownerDocument->getElementById($formId);
if (null === $form) {
throw new \LogicException(sprintf('The selected node has an invalid form attribute (%s).', $formId));
}
$this->node = $form;
return;
}
// we loop until we find a form ancestor
do {
if (null === $node = $node->parentNode) {
throw new \LogicException('The selected node does not have a form ancestor.');
}
} while ('form' !== $node->nodeName);
} elseif ('form' !== $node->nodeName) {
throw new \LogicException(sprintf('Unable to submit on a "%s" tag.', $node->nodeName));
}
$this->node = $node;
}
/**
* Adds form elements related to this form.
*
* Creates an internal copy of the submitted 'button' element and
* the form node or the entire document depending on whether we need
* to find non-descendant elements through HTML5 'form' attribute.
*/
private function initialize(): void
{
$this->fields = new FormFieldRegistry();
$xpath = new \DOMXPath($this->node->ownerDocument);
// add submitted button if it has a valid name
if ('form' !== $this->button->nodeName && $this->button->hasAttribute('name') && $this->button->getAttribute('name')) {
if ('input' == $this->button->nodeName && 'image' == strtolower($this->button->getAttribute('type'))) {
$name = $this->button->getAttribute('name');
$this->button->setAttribute('value', '0');
// temporarily change the name of the input node for the x coordinate
$this->button->setAttribute('name', $name.'.x');
$this->set(new Field\InputFormField($this->button));
// temporarily change the name of the input node for the y coordinate
$this->button->setAttribute('name', $name.'.y');
$this->set(new Field\InputFormField($this->button));
// restore the original name of the input node
$this->button->setAttribute('name', $name);
} else {
$this->set(new Field\InputFormField($this->button));
}
}
// find form elements corresponding to the current form
if ($this->node->hasAttribute('id')) {
// corresponding elements are either descendants or have a matching HTML5 form attribute
$formId = Crawler::xpathLiteral($this->node->getAttribute('id'));
$fieldNodes = $xpath->query(sprintf('( descendant::input[@form=%s] | descendant::button[@form=%1$s] | descendant::textarea[@form=%1$s] | descendant::select[@form=%1$s] | //form[@id=%1$s]//input[not(@form)] | //form[@id=%1$s]//button[not(@form)] | //form[@id=%1$s]//textarea[not(@form)] | //form[@id=%1$s]//select[not(@form)] )[not(ancestor::template)]', $formId));
foreach ($fieldNodes as $node) {
$this->addField($node);
}
} else {
// do the xpath query with $this->node as the context node, to only find descendant elements
// however, descendant elements with form attribute are not part of this form
$fieldNodes = $xpath->query('( descendant::input[not(@form)] | descendant::button[not(@form)] | descendant::textarea[not(@form)] | descendant::select[not(@form)] )[not(ancestor::template)]', $this->node);
foreach ($fieldNodes as $node) {
$this->addField($node);
}
}
if ($this->baseHref && '' !== $this->node->getAttribute('action')) {
$this->currentUri = $this->baseHref;
}
}
private function addField(\DOMElement $node): void
{
if (!$node->hasAttribute('name') || !$node->getAttribute('name')) {
return;
}
$nodeName = $node->nodeName;
if ('select' == $nodeName || 'input' == $nodeName && 'checkbox' == strtolower($node->getAttribute('type'))) {
$this->set(new Field\ChoiceFormField($node));
} elseif ('input' == $nodeName && 'radio' == strtolower($node->getAttribute('type'))) {
// there may be other fields with the same name that are no choice
// fields already registered (see https://github.com/symfony/symfony/issues/11689)
if ($this->has($node->getAttribute('name')) && $this->get($node->getAttribute('name')) instanceof ChoiceFormField) {
$this->get($node->getAttribute('name'))->addChoice($node);
} else {
$this->set(new Field\ChoiceFormField($node));
}
} elseif ('input' == $nodeName && 'file' == strtolower($node->getAttribute('type'))) {
$this->set(new Field\FileFormField($node));
} elseif ('input' == $nodeName && !\in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image'])) {
$this->set(new Field\InputFormField($node));
} elseif ('textarea' == $nodeName) {
$this->set(new Field\TextareaFormField($node));
}
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler;
use Symfony\Component\DomCrawler\Field\FormField;
/**
* This is an internal class that must not be used directly.
*
* @internal
*/
class FormFieldRegistry
{
private array $fields = [];
private string $base = '';
/**
* Adds a field to the registry.
*/
public function add(FormField $field): void
{
$segments = $this->getSegments($field->getName());
$target = &$this->fields;
while ($segments) {
if (!\is_array($target)) {
$target = [];
}
$path = array_shift($segments);
if ('' === $path) {
$target = &$target[];
} else {
$target = &$target[$path];
}
}
$target = $field;
}
/**
* Removes a field based on the fully qualified name and its children from the registry.
*/
public function remove(string $name): void
{
$segments = $this->getSegments($name);
$target = &$this->fields;
while (\count($segments) > 1) {
$path = array_shift($segments);
if (!\is_array($target) || !\array_key_exists($path, $target)) {
return;
}
$target = &$target[$path];
}
unset($target[array_shift($segments)]);
}
/**
* Returns the value of the field based on the fully qualified name and its children.
*
* @return FormField|FormField[]|FormField[][]
*
* @throws \InvalidArgumentException if the field does not exist
*/
public function &get(string $name): FormField|array
{
$segments = $this->getSegments($name);
$target = &$this->fields;
while ($segments) {
$path = array_shift($segments);
if (!\is_array($target) || !\array_key_exists($path, $target)) {
throw new \InvalidArgumentException(sprintf('Unreachable field "%s".', $path));
}
$target = &$target[$path];
}
return $target;
}
/**
* Tests whether the form has the given field based on the fully qualified name.
*/
public function has(string $name): bool
{
try {
$this->get($name);
return true;
} catch (\InvalidArgumentException) {
return false;
}
}
/**
* Set the value of a field based on the fully qualified name and its children.
*
* @throws \InvalidArgumentException if the field does not exist
*/
public function set(string $name, mixed $value): void
{
$target = &$this->get($name);
if ((!\is_array($value) && $target instanceof Field\FormField) || $target instanceof Field\ChoiceFormField) {
$target->setValue($value);
} elseif (\is_array($value)) {
$registry = new static();
$registry->base = $name;
$registry->fields = $value;
foreach ($registry->all() as $k => $v) {
$this->set($k, $v);
}
} else {
throw new \InvalidArgumentException(sprintf('Cannot set value on a compound field "%s".', $name));
}
}
/**
* Returns the list of field with their value.
*
* @return FormField[] The list of fields as [string] Fully qualified name => (mixed) value)
*/
public function all(): array
{
return $this->walk($this->fields, $this->base);
}
/**
* Transforms a PHP array in a list of fully qualified name / value.
*/
private function walk(array $array, ?string $base = '', array &$output = []): array
{
foreach ($array as $k => $v) {
$path = empty($base) ? $k : sprintf('%s[%s]', $base, $k);
if (\is_array($v)) {
$this->walk($v, $path, $output);
} else {
$output[$path] = $v;
}
}
return $output;
}
/**
* Splits a field name into segments as a web browser would do.
*
* getSegments('base[foo][3][]') = ['base', 'foo, '3', ''];
*
* @return string[]
*/
private function getSegments(string $name): array
{
if (preg_match('/^(?P<base>[^[]+)(?P<extra>(\[.*)|$)/', $name, $m)) {
$segments = [$m['base']];
while (!empty($m['extra'])) {
$extra = $m['extra'];
if (preg_match('/^\[(?P<segment>.*?)\](?P<extra>.*)$/', $extra, $m)) {
$segments[] = $m['segment'];
} else {
$segments[] = $extra;
}
}
return $segments;
}
return [$name];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler;
/**
* Image represents an HTML image (an HTML img tag).
*/
class Image extends AbstractUriElement
{
public function __construct(\DOMElement $node, string $currentUri = null)
{
parent::__construct($node, $currentUri, 'GET');
}
protected function getRawUri(): string
{
return $this->node->getAttribute('src');
}
/**
* @return void
*/
protected function setNode(\DOMElement $node)
{
if ('img' !== $node->nodeName) {
throw new \LogicException(sprintf('Unable to visualize a "%s" tag.', $node->nodeName));
}
$this->node = $node;
}
}
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2004-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+37
View File
@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler;
/**
* Link represents an HTML link (an HTML a, area or link tag).
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class Link extends AbstractUriElement
{
protected function getRawUri(): string
{
return $this->node->getAttribute('href');
}
/**
* @return void
*/
protected function setNode(\DOMElement $node)
{
if ('a' !== $node->nodeName && 'area' !== $node->nodeName && 'link' !== $node->nodeName) {
throw new \LogicException(sprintf('Unable to navigate from a "%s" tag.', $node->nodeName));
}
$this->node = $node;
}
}
+13
View File
@@ -0,0 +1,13 @@
DomCrawler Component
====================
The DomCrawler component eases DOM navigation for HTML and XML documents.
Resources
---------
* [Documentation](https://symfony.com/doc/current/components/dom_crawler.html)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)
@@ -0,0 +1,69 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\DomCrawler\Crawler;
final class CrawlerAnySelectorTextContains extends Constraint
{
private string $selector;
private string $expectedText;
private bool $hasNode = false;
public function __construct(string $selector, string $expectedText)
{
$this->selector = $selector;
$this->expectedText = $expectedText;
}
public function toString(): string
{
if ($this->hasNode) {
return sprintf('the text of any node matching selector "%s" contains "%s"', $this->selector, $this->expectedText);
}
return sprintf('the Crawler has a node matching selector "%s"', $this->selector);
}
protected function matches($other): bool
{
if (!$other instanceof Crawler) {
throw new \InvalidArgumentException(sprintf('"%s" constraint expected an argument of type "%s", got "%s".', self::class, Crawler::class, get_debug_type($other)));
}
$other = $other->filter($this->selector);
if (!\count($other)) {
$this->hasNode = false;
return false;
}
$this->hasNode = true;
$nodes = $other->each(fn (Crawler $node) => $node->text(null, true));
$matches = array_filter($nodes, function (string $node): bool {
return str_contains($node, $this->expectedText);
});
return 0 < \count($matches);
}
protected function failureDescription($other): string
{
if (!$other instanceof Crawler) {
throw new \InvalidArgumentException(sprintf('"%s" constraint expected an argument of type "%s", got "%s".', self::class, Crawler::class, get_debug_type($other)));
}
return $this->toString();
}
}
@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\DomCrawler\Crawler;
final class CrawlerAnySelectorTextSame extends Constraint
{
private string $selector;
private string $expectedText;
public function __construct(string $selector, string $expectedText)
{
$this->selector = $selector;
$this->expectedText = $expectedText;
}
public function toString(): string
{
return sprintf('has at least a node matching selector "%s" with content "%s"', $this->selector, $this->expectedText);
}
protected function matches($other): bool
{
if (!$other instanceof Crawler) {
throw new \InvalidArgumentException(sprintf('"%s" constraint expected an argument of type "%s", got "%s".', self::class, Crawler::class, get_debug_type($other)));
}
$other = $other->filter($this->selector);
if (!\count($other)) {
return false;
}
$nodes = $other->each(fn (Crawler $node) => trim($node->text(null, true)));
return \in_array($this->expectedText, $nodes, true);
}
protected function failureDescription($other): string
{
if (!$other instanceof Crawler) {
throw new \InvalidArgumentException(sprintf('"%s" constraint expected an argument of type "%s", got "%s".', self::class, Crawler::class, get_debug_type($other)));
}
return 'the Crawler '.$this->toString();
}
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\DomCrawler\Crawler;
final class CrawlerSelectorAttributeValueSame extends Constraint
{
private string $selector;
private string $attribute;
private string $expectedText;
public function __construct(string $selector, string $attribute, string $expectedText)
{
$this->selector = $selector;
$this->attribute = $attribute;
$this->expectedText = $expectedText;
}
public function toString(): string
{
return sprintf('has a node matching selector "%s" with attribute "%s" of value "%s"', $this->selector, $this->attribute, $this->expectedText);
}
/**
* @param Crawler $crawler
*/
protected function matches($crawler): bool
{
$crawler = $crawler->filter($this->selector);
if (!\count($crawler)) {
return false;
}
return $this->expectedText === trim($crawler->attr($this->attribute) ?? '');
}
/**
* @param Crawler $crawler
*/
protected function failureDescription($crawler): string
{
return 'the Crawler '.$this->toString();
}
}
@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\DomCrawler\Crawler;
final class CrawlerSelectorCount extends Constraint
{
public function __construct(
private readonly int $count,
private readonly string $selector,
) {
}
public function toString(): string
{
return sprintf('selector "%s" count is "%d"', $this->selector, $this->count);
}
/**
* @param Crawler $crawler
*/
protected function matches($crawler): bool
{
return $this->count === \count($crawler->filter($this->selector));
}
/**
* @param Crawler $crawler
*/
protected function failureDescription($crawler): string
{
return sprintf('the Crawler selector "%s" was expected to be found %d time(s) but was found %d time(s)', $this->selector, $this->count, \count($crawler->filter($this->selector)));
}
}
@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\DomCrawler\Crawler;
final class CrawlerSelectorExists extends Constraint
{
private string $selector;
public function __construct(string $selector)
{
$this->selector = $selector;
}
public function toString(): string
{
return sprintf('matches selector "%s"', $this->selector);
}
/**
* @param Crawler $crawler
*/
protected function matches($crawler): bool
{
return 0 < \count($crawler->filter($this->selector));
}
/**
* @param Crawler $crawler
*/
protected function failureDescription($crawler): string
{
return 'the Crawler '.$this->toString();
}
}
@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\DomCrawler\Crawler;
final class CrawlerSelectorTextContains extends Constraint
{
private string $selector;
private string $expectedText;
private bool $hasNode = false;
private string $nodeText;
public function __construct(string $selector, string $expectedText)
{
$this->selector = $selector;
$this->expectedText = $expectedText;
}
public function toString(): string
{
if ($this->hasNode) {
return sprintf('the text "%s" of the node matching selector "%s" contains "%s"', $this->nodeText, $this->selector, $this->expectedText);
}
return sprintf('the Crawler has a node matching selector "%s"', $this->selector);
}
/**
* @param Crawler $crawler
*/
protected function matches($crawler): bool
{
$crawler = $crawler->filter($this->selector);
if (!\count($crawler)) {
$this->hasNode = false;
return false;
}
$this->hasNode = true;
$this->nodeText = $crawler->text(null, true);
return str_contains($this->nodeText, $this->expectedText);
}
/**
* @param Crawler $crawler
*/
protected function failureDescription($crawler): string
{
return $this->toString();
}
}
@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler\Test\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
use Symfony\Component\DomCrawler\Crawler;
final class CrawlerSelectorTextSame extends Constraint
{
private string $selector;
private string $expectedText;
public function __construct(string $selector, string $expectedText)
{
$this->selector = $selector;
$this->expectedText = $expectedText;
}
public function toString(): string
{
return sprintf('has a node matching selector "%s" with content "%s"', $this->selector, $this->expectedText);
}
/**
* @param Crawler $crawler
*/
protected function matches($crawler): bool
{
$crawler = $crawler->filter($this->selector);
if (!\count($crawler)) {
return false;
}
return $this->expectedText === trim($crawler->text(null, true));
}
/**
* @param Crawler $crawler
*/
protected function failureDescription($crawler): string
{
return 'the Crawler '.$this->toString();
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DomCrawler;
/**
* The UriResolver class takes an URI (relative, absolute, fragment, etc.)
* and turns it into an absolute URI against another given base URI.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
class UriResolver
{
/**
* Resolves a URI according to a base URI.
*
* For example if $uri=/foo/bar and $baseUri=https://symfony.com it will
* return https://symfony.com/foo/bar
*
* If the $uri is not absolute you must pass an absolute $baseUri
*/
public static function resolve(string $uri, ?string $baseUri): string
{
$uri = trim($uri);
// absolute URL?
if (null !== parse_url($uri, \PHP_URL_SCHEME)) {
return $uri;
}
if (null === $baseUri) {
throw new \InvalidArgumentException('The URI is relative, so you must define its base URI passing an absolute URL.');
}
// empty URI
if (!$uri) {
return $baseUri;
}
// an anchor
if ('#' === $uri[0]) {
return self::cleanupAnchor($baseUri).$uri;
}
$baseUriCleaned = self::cleanupUri($baseUri);
if ('?' === $uri[0]) {
return $baseUriCleaned.$uri;
}
// absolute URL with relative schema
if (str_starts_with($uri, '//')) {
return preg_replace('#^([^/]*)//.*$#', '$1', $baseUriCleaned).$uri;
}
$baseUriCleaned = preg_replace('#^(.*?//[^/]*)(?:\/.*)?$#', '$1', $baseUriCleaned);
// absolute path
if ('/' === $uri[0]) {
return $baseUriCleaned.$uri;
}
// relative path
$path = parse_url(substr($baseUri, \strlen($baseUriCleaned)), \PHP_URL_PATH) ?? '';
$path = self::canonicalizePath(substr($path, 0, strrpos($path, '/')).'/'.$uri);
return $baseUriCleaned.('' === $path || '/' !== $path[0] ? '/' : '').$path;
}
/**
* Returns the canonicalized URI path (see RFC 3986, section 5.2.4).
*/
private static function canonicalizePath(string $path): string
{
if ('' === $path || '/' === $path) {
return $path;
}
if (str_ends_with($path, '.')) {
$path .= '/';
}
$output = [];
foreach (explode('/', $path) as $segment) {
if ('..' === $segment) {
array_pop($output);
} elseif ('.' !== $segment) {
$output[] = $segment;
}
}
return implode('/', $output);
}
/**
* Removes the query string and the anchor from the given uri.
*/
private static function cleanupUri(string $uri): string
{
return self::cleanupQuery(self::cleanupAnchor($uri));
}
/**
* Removes the query string from the uri.
*/
private static function cleanupQuery(string $uri): string
{
if (false !== $pos = strpos($uri, '?')) {
return substr($uri, 0, $pos);
}
return $uri;
}
/**
* Removes the anchor from the uri.
*/
private static function cleanupAnchor(string $uri): string
{
if (false !== $pos = strpos($uri, '#')) {
return substr($uri, 0, $pos);
}
return $uri;
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "symfony/dom-crawler",
"type": "library",
"description": "Eases DOM navigation for HTML and XML documents",
"keywords": [],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.1",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.0",
"masterminds/html5": "^2.6"
},
"require-dev": {
"symfony/css-selector": "^5.4|^6.0|^7.0"
},
"autoload": {
"psr-4": { "Symfony\\Component\\DomCrawler\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}

Some files were not shown because too many files have changed in this diff Show More