[a-z0-9_~\-]) (?[!$&\'()*+,;=]) (?%[A-F0-9]{2}) (?(?:(?&unreserved)|(?&sub_delims)|(?&encoded)){1,63}) ) ^(?:(?®_name)\.){0,126}(?®_name)\.?$/ix'; private const REGEXP_URI_DELIMITERS = '/[:\/?#\[\]@ ]/'; /** @var list */ private readonly array $labels; private readonly ?string $domain; /** * @throws CannotProcessHost */ private function __construct(private readonly string $type, DomainNameProvider|Host|Stringable|string|int|null $domain) { $this->domain = $this->parseDomain($domain); $this->labels = null === $this->domain ? [] : array_reverse(explode('.', $this->domain)); } /** * @param array{domain:string|null, type:string} $properties * * @throws CannotProcessHost */ public static function __set_state(array $properties): self { return new self($properties['type'], $properties['domain']); } /** * @throws CannotProcessHost */ public static function fromIDNA2003(DomainNameProvider|Host|Stringable|string|int|null $domain): self { return new self(self::IDNA_2003, $domain); } /** * @throws CannotProcessHost */ public static function fromIDNA2008(DomainNameProvider|Host|Stringable|string|int|null $domain): self { return new self(self::IDNA_2008, $domain); } /** * @throws CannotProcessHost */ private function parseDomain(DomainNameProvider|Host|Stringable|string|int|null $domain): ?string { return $this->parseValue(match (true) { $domain instanceof DomainNameProvider => $domain->domain()->value(), $domain instanceof Host => $domain->toUnicode()->value(), default => $domain, }); } /** * Parse and format the domain to ensure it is valid. * Returns an array containing the formatted domain name labels * and the domain transitional information. * * For example: parse('wWw.uLb.Ac.be') should return ['www.ulb.ac.be', ['be', 'ac', 'ulb', 'www']];. * * @throws SyntaxError If the host is not a domain * @throws SyntaxError If the domain is not a host */ private function parseValue(Stringable|string|int|null $domain): ?string { if (null === $domain) { return null; } if ($domain instanceof Stringable) { $domain = (string) $domain; } $domain = (string) $domain; if ('' === $domain) { return ''; } $res = filter_var($domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); if (false !== $res) { return $res; } $formattedDomain = rawurldecode($domain); return match (true) { 1 === preg_match(self::REGEXP_REGISTERED_NAME, $formattedDomain) => strtolower($formattedDomain), // a domain name can not contain URI delimiters or space 1 === preg_match(self::REGEXP_URI_DELIMITERS, $formattedDomain) => throw SyntaxError::dueToInvalidCharacters($domain), // if the domain name does not contain UTF-8 chars then it is malformed 1 !== preg_match(self::REGEXP_IDN_PATTERN, $formattedDomain) => throw SyntaxError::dueToMalformedValue($domain), default => $this->domainToUnicode($this->domainToAscii($formattedDomain)), }; } private function domainToAscii(string $domain): string { return Idna::toAscii( $domain, self::IDNA_2003 === $this->type ? Idna::IDNA2003_ASCII : Idna::IDNA2008_ASCII )->result(); } private function domainToUnicode(string $domain): string { return Idna::toUnicode( $domain, self::IDNA_2003 === $this->type ? Idna::IDNA2003_UNICODE : Idna::IDNA2008_UNICODE )->result(); } /** * @return Iterator */ public function getIterator(): Iterator { yield from $this->labels; } public function isAscii(): bool { return null === $this->domain || 1 !== preg_match(self::REGEXP_IDN_PATTERN, $this->domain); } public function jsonSerialize(): ?string { return $this->domain; } public function count(): int { return count($this->labels); } public function value(): ?string { return $this->domain; } public function toString(): string { return (string) $this->domain; } public function label(int $key): ?string { if ($key < 0) { $key += count($this->labels); } return $this->labels[$key] ?? null; } /** * @return list */ public function keys(?string $label = null): array { if (null === $label) { return array_keys($this->labels); } return array_keys($this->labels, $label, true); } /** * @return list */ public function labels(): array { return $this->labels; } public function toAscii(): self { if (null === $this->domain) { return $this; } $domain = $this->domainToAscii($this->domain); if ($domain === $this->domain) { return $this; } return new self($this->type, $domain); } public function toUnicode(): self { if (null === $this->domain) { return $this; } $domain = $this->domainToUnicode($this->domain); if ($domain === $this->domain) { return $this; } return new self($this->type, $domain); } /** * Filter a subdomain to update the domain part. */ private function normalize(DomainNameProvider|Host|Stringable|string|int|null $domain): ?string { if ($domain instanceof DomainNameProvider) { $domain = $domain->domain(); } if ($domain instanceof Host) { $domain = $domain->value(); } if (null === $domain) { return null; } $domain = (string) $domain; return match (true) { null === $this->domain => $domain, $this->isAscii() => $this->domainToAscii($domain), default => $this->domainToUnicode($domain), }; } /** * @throws CannotProcessHost */ public function prepend(DomainNameProvider|Host|Stringable|string|int|null $label): self { return $this->withLabel(count($this->labels), $label); } /** * @throws CannotProcessHost */ public function append(DomainNameProvider|Host|Stringable|string|int|null $label): self { return $this->withLabel(- count($this->labels) - 1, $label); } public function withLabel(int $key, DomainNameProvider|Host|Stringable|string|int|null $label): self { $nbLabels = count($this->labels); if ($key < - $nbLabels - 1 || $key > $nbLabels) { throw SyntaxError::dueToInvalidLabelKey($this, $key); } if (0 > $key) { $key = $nbLabels + $key; } $label = $this->normalize($label); if (($this->labels[$key] ?? null) === $label) { return $this; } $labels = $this->labels; $labels[$key] = $label; ksort($labels); return new self($this->type, implode('.', array_reverse($labels))); } public function withoutLabel(int ...$keys): self { $nbLabels = count($this->labels); foreach ($keys as &$offset) { if (- $nbLabels > $offset || $nbLabels - 1 < $offset) { throw SyntaxError::dueToInvalidLabelKey($this, $offset); } if (0 > $offset) { $offset += $nbLabels; } } unset($offset); $deletedKeys = array_keys(array_count_values($keys)); $labels = []; foreach ($this->labels as $offset => $label) { if (!in_array($offset, $deletedKeys, true)) { $labels[] = $label; } } if ($labels === $this->labels) { return $this; } return new self($this->type, [] === $labels ? null : implode('.', array_reverse($labels))); } /** * @throws CannotProcessHost */ public function clear(): self { if (null === $this->domain) { return $this; } return new self($this->type, null); } /** * @throws CannotProcessHost */ public function slice(int $offset, ?int $length = null): self { $nbLabels = count($this->labels); if ($offset < - $nbLabels || $offset > $nbLabels) { throw SyntaxError::dueToInvalidLabelKey($this, $offset); } $labels = array_slice($this->labels, $offset, $length, true); if ($labels === $this->labels) { return $this; } return new self($this->type, [] === $labels ? null : implode('.', array_reverse($labels))); } public function withRootLabel(): self { if ('' === $this->label(0)) { return $this; } return $this->append(''); } public function withoutRootLabel(): self { if ('' === $this->label(0)) { return $this->slice(1); } return $this; } public function isAbsolute(): bool { return '' === $this->label(0); } /** * Apply the callback if the given "condition" is (or resolves to) true. * * @param (callable($this): bool)|bool $condition * @param callable($this): (self|null) $onSuccess * @param ?callable($this): (self|null) $onFail * */ public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self { if (!is_bool($condition)) { $condition = $condition($this); } return match (true) { $condition => $onSuccess($this), null !== $onFail => $onFail($this), default => $this, } ?? $this; } }