themichaelhall/datatypes

View on GitHub
src/Net/UrlPath.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

/**
 * This file is a part of the datatypes package.
 *
 * https://github.com/themichaelhall/datatypes
 */

declare(strict_types=1);

namespace DataTypes\Net;

use DataTypes\Net\Exceptions\UrlPathInvalidArgumentException;
use DataTypes\Net\Exceptions\UrlPathLogicException;

/**
 * Class representing a url path.
 *
 * @since 1.0.0
 */
class UrlPath implements UrlPathInterface
{
    /**
     * Returns true if the url path equals other url path, false otherwise.
     *
     * @since 1.2.0
     *
     * @param UrlPathInterface $urlPath The other url path.
     *
     * @return bool True if the url path equals other url path, false otherwise.
     */
    public function equals(UrlPathInterface $urlPath): bool
    {
        return $this->isAbsolute() === $urlPath->isAbsolute() && $this->getDirectoryParts() === $urlPath->getDirectoryParts() && $this->getFilename() === $urlPath->getFilename();
    }

    /**
     * Returns the depth of the url path.
     *
     * @since 1.0.0
     *
     * @return int The depth of the url path.
     */
    public function getDepth(): int
    {
        return count($this->directoryParts) - $this->aboveBaseLevelCount;
    }

    /**
     * Returns the directory of the url path.
     *
     * @since 1.0.0
     *
     * @return UrlPathInterface The directory of the url path.
     */
    public function getDirectory(): UrlPathInterface
    {
        return new self($this->isAbsolute, $this->aboveBaseLevelCount, $this->directoryParts, null);
    }

    /**
     * Returns the directory parts.
     *
     * @since 1.0.0
     *
     * @return string[] The directory parts.
     */
    public function getDirectoryParts(): array
    {
        return $this->aboveBaseLevelCount === 0 ? $this->directoryParts : array_merge(array_fill(0, $this->aboveBaseLevelCount, '..'), $this->directoryParts);
    }

    /**
     * Returns the filename or null if the url path is a directory.
     *
     * @since 1.0.0
     *
     * @return string|null The filename or null if the url path is a directory.
     */
    public function getFilename(): ?string
    {
        return $this->filename;
    }

    /**
     * Returns the parent directory of the url path or null if url path does not have a parent directory.
     *
     * @since 1.0.0
     *
     * @return UrlPathInterface|null The parent directory of the url path or null if url path does not have a parent directory.
     */
    public function getParentDirectory(): ?UrlPathInterface
    {
        if (!$this->hasParentDirectory()) {
            return null;
        }

        if (count($this->directoryParts) === 0) {
            return new self($this->isAbsolute, $this->aboveBaseLevelCount + 1, $this->directoryParts, null);
        }

        return new self($this->isAbsolute, $this->aboveBaseLevelCount, array_slice($this->directoryParts, 0, -1), null);
    }

    /**
     * Returns true if url path has a parent directory, false otherwise.
     *
     * @since 1.0.0
     *
     * @return bool True if url path has a parent directory, false otherwise.
     */
    public function hasParentDirectory(): bool
    {
        return $this->isRelative() || count($this->directoryParts) > 0;
    }

    /**
     * Returns true if url path is absolute, false otherwise.
     *
     * @since 1.0.0
     *
     * @return bool True if url path is absolute, false otherwise.
     */
    public function isAbsolute(): bool
    {
        return $this->isAbsolute;
    }

    /**
     * Returns true if url path is a directory, false otherwise.
     *
     * @since 1.0.0
     *
     * @return bool True if url path is a directory, false otherwise.
     */
    public function isDirectory(): bool
    {
        return $this->filename === null;
    }

    /**
     * Returns true if url path is a file, false otherwise.
     *
     * @since 1.0.0
     *
     * @return bool True if url path is a file, false otherwise.
     */
    public function isFile(): bool
    {
        return $this->filename !== null;
    }

    /**
     * Returns true if url path is relative, false otherwise.
     *
     * @since 1.0.0
     *
     * @return bool True if url path is relative, false otherwise.
     */
    public function isRelative(): bool
    {
        return !$this->isAbsolute;
    }

    /**
     * Returns a copy of the url path as an absolute path.
     *
     * @since 1.0.0
     *
     * @throws UrlPathLogicException if the url path could not be made absolute.
     *
     * @return UrlPathInterface The url path as an absolute path.
     */
    public function toAbsolute(): UrlPathInterface
    {
        if ($this->aboveBaseLevelCount > 0) {
            throw new UrlPathLogicException('Url path "' . $this->__toString() . '" can not be made absolute: Relative path is above base level.');
        }

        return new self(true, $this->aboveBaseLevelCount, $this->directoryParts, $this->filename);
    }

    /**
     * Returns a copy of the url path as a relative path.
     *
     * @since 1.0.0
     *
     * @return UrlPathInterface The url path as a relative path.
     */
    public function toRelative(): UrlPathInterface
    {
        return new self(false, $this->aboveBaseLevelCount, $this->directoryParts, $this->filename);
    }

    /**
     * Returns a copy of the url path with another filename.
     *
     * @since 2.2.0
     *
     * @param string $filename The other filename
     *
     * @throws UrlPathInvalidArgumentException if the filename is invalid.
     *
     * @return UrlPathInterface The new url path.
     */
    public function withFilename(string $filename): UrlPathInterface
    {
        if (!self::validatePart($filename, false, $error)) {
            throw new UrlPathInvalidArgumentException($error);
        }

        return new self($this->isAbsolute, $this->aboveBaseLevelCount, $this->directoryParts, $filename);
    }

    /**
     * Returns a copy of the url path combined with another url path.
     *
     * @since 1.0.0
     *
     * @param UrlPathInterface $urlPath The other url path.
     *
     * @throws UrlPathLogicException if the url paths could not be combined.
     *
     * @return UrlPathInterface The combined url path.
     */
    public function withUrlPath(UrlPathInterface $urlPath): UrlPathInterface
    {
        $filename = $urlPath->getFilename();

        if ($urlPath->isAbsolute()) {
            return new self(true, 0, $urlPath->getDirectoryParts(), $filename);
        }

        $directoryParts = array_merge($this->directoryParts, $urlPath->getDirectoryParts());
        $aboveBaseLevel = $this->aboveBaseLevelCount;

        if (!self::normalizeDirectoryParts($this->isAbsolute, $directoryParts, $aboveBaseLevel, $error)) {
            throw new UrlPathLogicException('Url path "' . $this->__toString() . '" can not be combined with url path "' . $urlPath->__toString() . '": ' . $error);
        }

        return new self($this->isAbsolute, $aboveBaseLevel, $directoryParts, $filename);
    }

    /**
     * Returns the url path as a string.
     *
     * @since 1.0.0
     *
     * @return string The url path as a string.
     */
    public function __toString(): string
    {
        $parts = [];

        if ($this->aboveBaseLevelCount > 0) {
            $parts = array_fill(0, $this->aboveBaseLevelCount, '..');
        }

        if ($this->isAbsolute) {
            $parts[] = '';
        }

        $parts = array_merge($parts, $this->directoryParts);
        $parts[] = $this->filename ?? '';

        $parts = array_map(function (string $part): string {
            return rawurlencode($part);
        }, $parts);

        return implode(self::DIRECTORY_SEPARATOR, $parts);
    }

    /**
     * Checks if a url path is valid.
     *
     * @since 1.0.0
     *
     * @param string $string The url path.
     *
     * @return bool True if the url path parameter is a valid url path, false otherwise.
     */
    public static function isValid(string $string): bool
    {
        return self::doParse($string, false) !== null;
    }

    /**
     * Parses a url path.
     *
     * @since 1.0.0
     *
     * @param string $string The url path.
     *
     * @throws UrlPathInvalidArgumentException If the url path parameter is not a valid url path.
     *
     * @return UrlPathInterface The url path instance.
     */
    public static function parse(string $string): UrlPathInterface
    {
        $result = self::doParse($string, false, $error);
        if ($result === null) {
            throw new UrlPathInvalidArgumentException('Url path "' . $string . '" is invalid: ' . $error);
        }

        return $result;
    }

    /**
     * Parses a url path as a directory, regardless if the input ends with a directory separator or not.
     *
     * @since 2.2.0
     *
     * @param string $string The url path.
     *
     * @throws UrlPathInvalidArgumentException If the url path parameter is not a valid url path.
     *
     * @return UrlPathInterface The url path instance.
     */
    public static function parseAsDirectory(string $string): UrlPathInterface
    {
        $result = self::doParse($string, true, $error);
        if ($result === null) {
            throw new UrlPathInvalidArgumentException('Url path "' . $string . '" is invalid: ' . $error);
        }

        return $result;
    }

    /**
     * Parses a url path.
     *
     * @since 1.0.0
     *
     * @param string $string The url path.
     *
     * @return UrlPathInterface|null The url path instance if the url path parameter is a valid url path, null otherwise.
     */
    public static function tryParse(string $string): ?UrlPathInterface
    {
        return self::doParse($string, false);
    }

    /**
     * Parses a url path as a directory, regardless if the input ends with a directory separator or not.
     *
     * @since 2.2.0
     *
     * @param string $string The url path.
     *
     * @return UrlPathInterface|null The url path instance if the url path parameter is a valid url path, null otherwise.
     */
    public static function tryParseAsDirectory(string $string): ?UrlPathInterface
    {
        $result = self::doParse($string, true);
        if ($result === null) {
            return null;
        }

        return $result;
    }

    /**
     * Constructs a url path from value.
     *
     * @param bool        $isAbsolute     If true url path is absolute, if false url path is relative.
     * @param int         $aboveBaseLevel The number of directory parts above base level.
     * @param string[]    $directoryParts The directory parts.
     * @param string|null $filename       The filename.
     */
    private function __construct(bool $isAbsolute, int $aboveBaseLevel, array $directoryParts, ?string $filename)
    {
        $this->isAbsolute = $isAbsolute;
        $this->aboveBaseLevelCount = $aboveBaseLevel;
        $this->directoryParts = $directoryParts;
        $this->filename = $filename;
    }

    /**
     * Tries to parse a url path and returns the result or null.
     *
     * @param string      $str              The url path to parse.
     * @param bool        $parseAsDirectory If true, always parse url path as a directory.
     * @param string|null $error            The error text if parsing was not successful, undefined otherwise.
     *
     * @return self|null The url path if parsing was successful, null otherwise.
     */
    private static function doParse(string $str, bool $parseAsDirectory, ?string &$error = null): ?self
    {
        $parts = explode(self::DIRECTORY_SEPARATOR, $str);

        $isAbsolute = false;
        $directoryParts = [];
        $filename = null;

        if (!self::parseParts($parts, $parseAsDirectory, $isAbsolute, $directoryParts, $filename, $error)) {
            return null;
        }

        $aboveBaseLevel = 0;

        if (!self::normalizeDirectoryParts($isAbsolute, $directoryParts, $aboveBaseLevel, $error)) {
            return null;
        }

        return new self($isAbsolute, $aboveBaseLevel, $directoryParts, $filename);
    }

    /**
     * Parses the url path parts.
     *
     * @param string[]    $parts            The parts to parse.
     * @param bool        $parseAsDirectory If true, always parse parts as a directory.
     * @param bool        $isAbsolute       True if parts represents an absolute path, false otherwise.
     * @param array       $directoryParts   The parsed directory parts.
     * @param string|null $filename         The parsed file name.
     * @param string|null $error            The error text if parsing was not successful, undefined otherwise.
     *
     * @return bool True if parsing was successful, false otherwise.
     */
    private static function parseParts(array $parts, bool $parseAsDirectory, bool &$isAbsolute, array &$directoryParts, ?string &$filename, ?string &$error): bool
    {
        $isAbsolute = count($parts) > 1 && $parts[0] === '';
        if ($isAbsolute) {
            array_shift($parts);
        }

        $directoryParts = [];
        $filename = null;

        foreach ($parts as $index => $part) {
            $isLast = $index === count($parts) - 1;

            if (!$parseAsDirectory && $isLast && $part !== '.' && $part !== '..') {
                if (!self::validatePart($part, false, $error)) {
                    return false;
                }

                $filename = $part !== '' ? rawurldecode($part) : null;

                continue;
            }

            if (!self::validatePart($part, true, $error)) {
                return false;
            }

            $directoryParts[] = rawurldecode($part);
        }

        return true;
    }

    /**
     * Normalizes directory parts, i.e. handles parts that are any of "", ".", "..".
     *
     * @param bool        $isAbsolute     True if parts represents an absolute path, false otherwise.
     * @param string[]    $parts          The directory parts.
     * @param int         $aboveBaseLevel The number of directory parts above base level.
     * @param string|null $error          The error text if normalizing was not successful, undefined otherwise.
     *
     * @return bool True if normalizing was successful, false otherwise.
     */
    private static function normalizeDirectoryParts(bool $isAbsolute, array &$parts, int &$aboveBaseLevel, ?string &$error = null): bool
    {
        $newParts = [];

        foreach ($parts as $part) {
            if ($part === '' || $part === '.') {
                continue;
            }

            if ($part === '..') {
                if (count($newParts) > 0) {
                    array_pop($newParts);

                    continue;
                }

                if ($isAbsolute) {
                    $error = 'Absolute path is above root level.';

                    return false;
                }

                $aboveBaseLevel++;

                continue;
            }

            $newParts[] = $part;
        }

        $parts = $newParts;

        return true;
    }

    /**
     * Validates a directory part name or a file name.
     *
     * @param string      $part        The part to validate.
     * @param bool        $isDirectory If true part is a directory part name, if false part is a file name.
     * @param string|null $error       The error text if validation was not successful, undefined otherwise.
     *
     * @return bool True if validation was successful, false otherwise.
     */
    private static function validatePart(string $part, bool $isDirectory, ?string &$error): bool
    {
        if (preg_match('/[^0-9a-zA-Z._~!\$&\'()*+,;=:@\[\]%-]/', $part, $matches)) {
            $error = ($isDirectory ? 'Part of directory' : 'Filename') . ' "' . $part . '" contains invalid character "' . $matches[0] . '".';

            return false;
        }

        return true;
    }

    /**
     * @var int The number of directory parts above base level.
     */
    private int $aboveBaseLevelCount;

    /**
     * @var string[] The directory parts.
     */
    private array $directoryParts;

    /**
     * @var string|null The filename.
     */
    private ?string $filename;

    /**
     * @var bool True if path is absolute, false otherwise.
     */
    private bool $isAbsolute;

    /**
     * @var string The directory separator.
     */
    private const DIRECTORY_SEPARATOR = '/';
}