Compare commits

...

25 Commits

  1. 3
      .gitignore
  2. 69
      .gitlab-ci.yml
  3. 7
      CHANGELOG.md
  4. 25
      LICENSE.md
  5. 55
      README.md
  6. 6
      composer.json
  7. 22
      docs/_templates/sidebar/brand.html
  8. 10
      docs/conf.py
  9. 73
      docs/helper_classes.rst
  10. 41
      docs/index.rst
  11. 159
      docs/path_classes.rst
  12. 131
      docs/path_interfaces.rst
  13. 1
      docs/requirements.txt
  14. 2
      phpunit.xml
  15. 52
      src/AbstractAbsolutePath.php
  16. 27
      src/AbstractPath.php
  17. 10
      src/FilesystemPath.php
  18. 46
      src/PathFactory.php
  19. 3
      src/PathInterface.php
  20. 66
      src/PathUtils.php
  21. 24
      src/RelativePath.php
  22. 39
      src/StreamPath.php
  23. 2
      src/UnixPath.php
  24. 57
      src/UrlPath.php
  25. 2
      src/WindowsPath.php
  26. 60
      tests/Classes/CustomRelativePathImplementation.php
  27. 24
      tests/FilesystemPathTest.php
  28. 55
      tests/PathFactoryTest.php
  29. 60
      tests/PathUtilsTest.php
  30. 138
      tests/RelativePathTest.php
  31. 282
      tests/StreamPathTest.php
  32. 116
      tests/UnixPathTest.php
  33. 235
      tests/UrlPathTest.php
  34. 13
      tests/WindowsPathTest.php

3
.gitignore vendored

@ -8,3 +8,6 @@
# phpunit
/.phpunit.result.cache
/reports
# docs
/docs/build

@ -0,0 +1,69 @@
stages:
- test
- report
cache:
key: composer-cache
paths:
- .composer-cache/
.test:
before_script:
# install system packages
- apt-get update && apt-get install -y git unzip
# install extensions
- if [ "$INSTALL_XDEBUG" -eq 1 ]; then curl --location https://github.com/FriendsOfPHP/pickle/releases/latest/download/pickle.phar --output pickle.phar; php pickle.phar install --defaults xdebug; docker-php-ext-enable xdebug; fi
# install composer
- php -r "copy('https://composer.github.io/installer.sig', '/tmp/composer.sig');"
- php -r "copy('https://getcomposer.org/installer', '/tmp/composer-setup.php');"
- php -r '$expected = file_get_contents("/tmp/composer.sig"); $actual = hash_file("sha384", "/tmp/composer-setup.php"); exit(intval(!hash_equals($expected, $actual)));'
- php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer
- chmod +x /usr/local/bin/composer
- rm /tmp/composer-setup.php /tmp/composer.sig
# cache dependencies
- composer config -g cache-dir "$(pwd)/.composer-cache"
script:
- composer update
- vendor/bin/phpunit
# lowest version with lowest dependencies
test-7.4-lowest:
extends: .test
stage: test
image: php:7.4
script:
- composer update --prefer-lowest
- vendor/bin/phpunit
# lowest version
test-7.4:
extends: .test
stage: test
image: php:7.4
## current release
test-8.0:
extends: .test
stage: test
image: php:8.0
# latest unstable
test-rc:
extends: .test
stage: test
image: php:rc
allow_failure: true
# coverage
coverage:
variables:
INSTALL_XDEBUG: 1
extends: .test
stage: report
only:
- master
image: php:8.0
script:
- composer update
- XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-clover coverage.xml
- bash <(curl -s https://codecov.io/bash)

@ -0,0 +1,7 @@
# Changelog
## 1.0.0
*Nov 6, 2021*
Initial release

@ -0,0 +1,25 @@
The MIT License (MIT)
=====================
Copyright © 2021 Anton Smirnov
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,55 @@
# PHP Path Library
[![Packagist](https://img.shields.io/packagist/v/arokettu/path.svg?style=flat-square)](https://packagist.org/packages/arokettu/path)
[![PHP](https://img.shields.io/packagist/php-v/arokettu/path.svg?style=flat-square)](https://packagist.org/packages/arokettu/path)
[![License](https://img.shields.io/packagist/l/arokettu/path.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![Gitlab pipeline status](https://img.shields.io/gitlab/pipeline/sandfox/php-path/master.svg?style=flat-square)](https://gitlab.com/sandfox/php-path/-/pipelines)
[![Codecov](https://img.shields.io/codecov/c/gl/sandfox/php-path?style=flat-square)](https://codecov.io/gl/sandfox/php-path/)
A PHP library to work with absolute and relative paths.
## Usage
```php
<?php
use Arokettu\Path\PathUtils;
use Arokettu\Path\RelativePath;
use Arokettu\Path\UrlPath;
// simple interface
PathUtils::resolveRelativePath('/some/path', '../other/path');
// => /some/other/path
PathUtils::makeRelativePath('/some/path', '/some/other/path');
// => ../other/path
// OOP interface, more control
$url = UrlPath::parse('https://example.com/some/path');
$rel = RelativePath::unix('../other/path');
$url->resolveRelative($rel)->toString();
// => https://example.com/some/other/path
```
## Installation
```bash
composer require arokettu/path
```
## Documentation
Read full documentation here: <https://sandfox.dev/php/path.html>
Also on Read the Docs: <https://php-path.readthedocs.io/>
## Support
Please file issues on our main repo at GitLab: <https://gitlab.com/sandfox/path/-/issues>
## License
The library is available as open source under the terms of the [MIT License].
[MIT License]: https://opensource.org/licenses/MIT

@ -4,6 +4,7 @@
"keywords": ["paths", "relative path", "filesystem"],
"type": "library",
"license": "MIT",
"homepage": "https://sandfox.dev/php/path.html",
"authors": [
{
"name": "Anton Smirnov",
@ -14,7 +15,8 @@
],
"support": {
"source": "https://gitlab.com/sandfox/php-path",
"issues": "https://gitlab.com/sandfox/php-path/-/issues"
"issues": "https://gitlab.com/sandfox/php-path/-/issues",
"docs": "https://php-path.readthedocs.io/"
},
"config": {
"sort-packages": true
@ -36,7 +38,7 @@
"symfony/polyfill-php81": "^1.22"
},
"require-dev": {
"phpunit/phpunit": ">= 7 < 10",
"phpunit/phpunit": "^9.5",
"psy/psysh": "*",
"sandfox.dev/code-standard": "^10@dev",
"squizlabs/php_codesniffer": "*",

@ -0,0 +1,22 @@
<a class="sidebar-brand{% if logo %} centered{% endif %}" href="{{ pathto(master_doc) }}">
{% block brand_content %}
{%- if logo %}
<div class="sidebar-logo-container">
<img class="sidebar-logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/>
</div>
{%- endif %}
{%- if theme_light_logo and theme_dark_logo %}
<div class="sidebar-logo-container">
<img class="sidebar-logo only-light" src="{{ pathto('_static/' + theme_light_logo, 1) }}" alt="Light Logo"/>
<img class="sidebar-logo only-dark" src="{{ pathto('_static/' + theme_dark_logo, 1) }}" alt="Dark Logo"/>
</div>
{%- endif %}
{% if not theme_sidebar_hide_name %}
<span class="sidebar-brand-text">{{ docstitle }}</span>
{%- endif %}
{% endblock brand_content %}
</a>
{%- if current_version -%}
<div class="sidebar-brand">{{ current_version }}</div>
{%- endif -%}

@ -0,0 +1,10 @@
from datetime import datetime
project = 'Path Library'
author = 'Anton Smirnov'
copyright = '{} {}'.format(datetime.now().year, author)
language = 'en'
html_title = project
html_theme = 'furo'
templates_path = ["_templates"]

@ -0,0 +1,73 @@
Helper Classes
##############
PathFactory
===========
``parse()``
-----------
.. code-block:: php
<?php
PathFactory::parse(
string $path,
array $urlSchemes = [],
array $streamSchemes = []
): PathInterface;
The ``parse`` function tries to detect the type of the given string path in the following order:
* Unix path
* Windows path
* Url/Scheme paths:
* If both ``$urlSchemes`` and ``$streamSchemes`` are empty, all scheme prefixed paths are parsed as URLs.
* If at least one of the lists is non-empty, all unknown schemes throw an exception.
* Known schemes are parsed according to which list they belong.
* Relative path of the current OS type.
* Since there is no way to separate root relative path from Unix path, root relative paths are never returned.
All paths are returned by the ``parse()`` constructor in a non strict mode.
PathUtils
=========
``resolveRelativePath()``
-------------------------
.. code-block:: php
<?php
PathUtils::resolveRelativePath(
string|PathInterface $basePath,
string|PathInterface $relativePath
): string;
Resolves relative path from the base path.
If a string is passed: it goes through ``PathFactory::parse()``.
If ``relativePath`` is an absolute path, it is converted to string and returned.
Otherwise ``$basePath->resolveRelative($relativePath)->toString()`` is returned.
``makeRelativePath()``
----------------------
.. code-block:: php
<?php
PathUtils::makeRelativePath(
string|AbsolutePathInterface $basePath,
string|AbsolutePathInterface $targetPath
): string;
Makes relative path from two absolute paths.
If a string is passed: it goes through ``PathFactory::parse()``.
If any of the paths is a relative path, an exception is thrown.
Otherwise ``$basePath->makeRelative($targetPath)->toString()`` is returned.

@ -0,0 +1,41 @@
Path Library
############
|Packagist| |GitLab| |GitHub| |Bitbucket| |Gitea|
A PHP library to work with absolute and relative paths.
Installation
============
.. code-block:: bash
composer require arokettu/path
Documentation
=============
.. toctree::
:maxdepth: 2
path_interfaces
path_classes
helper_classes
License
=======
The library is available as open source under the terms of the `MIT License`_.
.. _MIT License: https://opensource.org/licenses/MIT
.. |Packagist| image:: https://img.shields.io/packagist/v/arokettu/path.svg?style=flat-square
:target: https://packagist.org/packages/arokettu/path
.. |GitHub| image:: https://img.shields.io/badge/get%20on-GitHub-informational.svg?style=flat-square&logo=github
:target: https://github.com/arokettu/php-path
.. |GitLab| image:: https://img.shields.io/badge/get%20on-GitLab-informational.svg?style=flat-square&logo=gitlab
:target: https://gitlab.com/sandfox/php-path
.. |Bitbucket| image:: https://img.shields.io/badge/get%20on-Bitbucket-informational.svg?style=flat-square&logo=bitbucket
:target: https://bitbucket.org/sandfox/php-path
.. |Gitea| image:: https://img.shields.io/badge/get%20on-Gitea-informational.svg?style=flat-square&logo=gitea
:target: https://sandfox.org/sandfox/php-path

@ -0,0 +1,159 @@
Path Classes
############
RelativePath
============
The only concrete implementation of ``RelativePathInterface``.
In non-root relative paths first component returned by ``getComponents()`` is either ``'.'`` or ``'..'``.
When resolving relative, windows-ness of the resulting relative will be inherited from the base path.
Available constructors:
.. code-block:: php
<?php
new RelativePath(string $path, bool $windows = false);
``$windows = false``: Unix-like path. Path separators are slashes.
``$windows = true``: Windows-like path. Path separators are both slashes and backslashes.
When exporting a string, backslashes are used.
.. code-block:: php
<?php
RelativePath::unix(string $path): self;
Same as ``new RelativePath($path, windows: false)``
.. code-block:: php
<?php
RelativePath::windows(string $path): self;
Same as ``new RelativePath($path, windows: true)``
.. code-block:: php
<?php
RelativePath::currentOS(string $path): self;
If Windows is detected, create a Windows-like path, otherwise create Unix-like path.
.. note:: Windows is detected by the ``DIRECTORY_SEPARATOR`` constant.
.. code-block:: php
<?php
RelativePath::parse(string $path): self;
Alias of ``currentOS()``.
FilesystemPath
==============
A base class for ``UnixPath`` and ``WindowsPath``.
No default constructor, only a named constructor is available:
.. code-block:: php
<?php
FilesystemPath::parse(string $path, bool $strict = false): self;
If Windows is detected, create a Windows path, otherwise create a Unix path.
Strict mode does not allow to have relative components that traverse beyond root.
.. note:: Windows is detected by the ``DIRECTORY_SEPARATOR`` constant.
.. code-block:: php
<?php
use Arokettu\Path\FilesystemPath;
// on windows
FilesystemPath::parse('C:\Windows\..\..\..\Users'); // C:\Users
FilesystemPath::parse('C:\Windows\..\..\..\Users' strict: true); // exception
UnixPath
--------
A class for Unix paths.
The prefix is ``'/'``
.. code-block:: php
<?php
// these are equal
new UnixPath(string $path, bool $strict = false);
UnixPath::parse(string $path, bool $strict = false): self;
WindowsPath
-----------
.. warning::
Windows usually have much more restrictions on file path than unix-like operating systems
like forbidding characters like ``|`` and ``:``.
The library doesn't check for that even in strict mode.
A class for Windows paths.
``makeRelative()`` returns relatives of the Windows-like type.
Supported paths:
* DOS-like paths.
The classic paths with a drive letter: ``C:\Path``.
Both slashes and backslashes are supported as component separators.
Relative components are resolved on creation like in most other classes here.
The prefix here is a drive letter.
* UNC paths.
Examples:
* Local paths like ``\\*\C:\Path``. The prefix here is ``\\*\C:\``.
* Network paths like ``\\AROKETTUPC\c$``. The prefix here is ``\\AROKETTUPC\``.
UNC paths do not allow forward slashes and relative components.
.. note::
Relative paths with drive letter like ``C:Path\Path`` are valid in Windows
but are not supported by the library in any way.
.. code-block:: php
<?php
// these are equal
new WindowsPath(string $path, bool $strict = false);
WindowsPath::parse(string $path, bool $strict = false): self;
UrlPath
=======
A class for URL paths.
The prefix is scheme + hostname.
.. code-block:: php
<?php
// these are equal
new UrlPath(string $path, bool $strict = false);
UrlPath::parse(string $path, bool $strict = false): self;
StreamPath
==========
A class for PHP stream like paths.
Examples include php streams like ``php://temp``.
It can be useful with libraries that create virtual file systems like `adlawson/vfs`_ and `mikey179/vfsstream`_.
The prefix is scheme.
.. _adlawson/vfs: https://packagist.org/packages/adlawson/vfs
.. _mikey179/vfsstream: https://packagist.org/packages/mikey179/vfsstream
.. code-block:: php
<?php
// these are equal
new StreamPath(string $path, bool $strict = false);
StreamPath::parse(string $path, bool $strict = false): self;

@ -0,0 +1,131 @@
Path Interfaces
###############
PathInterface
=============
``resolveRelative()``
---------------------
.. code-block:: php
<?php
public function resolveRelative(RelativePathInterface $path, bool $strict = false): self;
Convert relative path to absolute or combine two relative paths using the caller object as base.
.. code-block:: php
<?php
use Arokettu\Path\RelativePath;
use Arokettu\Path\UnixPath;
$path = UnixPath::parse('/some/path');
$rel1 = RelativePath::parse('../other/path');
// trailing slashes are preserved
$rel2 = RelativePath::parse('../diff/path/');
$path->resolveRelative($rel1); // /some/other/path
// trailing slash will be present if target path has it
$rel1->resolveRelative($rel2); // ../other/diff/path/
Strict mode throws exception if traversal happens beyond root (no effect if the base path is relative):
.. code-block:: php
<?php
use Arokettu\Path\RelativePath;
use Arokettu\Path\UnixPath;
$path = UnixPath::parse('/some/path');
$rel = RelativePath::parse('../../../../etc/passwd');
$path->resolveRelative($rel); // /etc/passwd
$path->resolveRelative($rel, strict: true); // exception
``getPrefix()``
---------------
Path prefix that you can't traverse beyond like root unix path, windows drive path (C:\\), and url hostname.
``getComponents()``
-------------------
An array of path components excluding prefix.
The last component of the path is empty string if path has trailing (back)slash
``isAbsolute()``
----------------
``true`` for instances of ``AbsolutePathInterface``.
``false`` for instances of ``RelativePathInterface``.
``isRelative()``
----------------
``false`` for instances of ``AbsolutePathInterface``.
``true`` for instances of ``RelativePathInterface``.
``toString()`` & ``__toString()``
---------------------------------
Get string value of the path.
AbsolutePathInterface
=====================
``makeRelative()``
------------------
.. code-block:: php
<?php
public function makeRelative(self $targetPath, ?\Closure $equals = null): RelativePathInterface;
Make relative path from base path and target path of the same type having equal prefixes.
The paths are treated as case sensitive unless ``$equals`` callback is provided.
.. code-block:: php
<?php
use Arokettu\Path\UnixPath;
use Arokettu\Path\UrlPath;
use Arokettu\Path\WindowsPath;
$path1 = UnixPath::parse('/home/arokettu');
$path2 = UnixPath::parse('/home/sandfox/');
// there will be a trailing slash if target path has it
$path1->makeRelative($path2); // ../sandfox/
// ignore case on Windows
$path1 = WindowsPath::parse('c:\users\arokettu');
$path2 = WindowsPath::parse('C:\Users\SandFox');
$path1->makeRelative(
$path2,
fn ($a, $b) => strtoupper($a) === strtoupper($b)
); // ..\SandFox
// resolve urlencoded url path
$path1 = UrlPath::parse('https://example.com/some%20path/child%20dir');
$path2 = UrlPath::parse('https://example.com/some path/child dir');
$path1->makeRelative(
$path2,
fn ($a, $b) => urldecode($a) === urldecode($b)
); // .
RelativePathInterface
=====================
``isRoot()``
------------
``true`` if the relative path is 'root path', i.e. full path excluding prefix.
Examples:
* ``\Users\SandFox`` for Windows path ``C:\Users\SandFox``
* ``/some path/child dir`` for UrlPath ``https://example.com/some path/child dir``
* Functionally equal to Unix path
When applying root path in ``resolveRelative()``, it replaces the whole path excluding prefix.

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8" ?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
convertDeprecationsToExceptions="true"
executionOrder="random"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd">
<testsuites>
<testsuite name="all">

@ -6,13 +6,21 @@ namespace Arokettu\Path;
use Arokettu\Path\Helpers\DataTypeHelper;
/**
* @internal
*/
abstract class AbstractAbsolutePath extends AbstractPath implements AbsolutePathInterface
{
public function isAbsolute(): bool
{
return true;
}
public function isRelative(): bool
{
return false;
}
/**
* @param static $targetPath
* @param \Closure $equals(string $a, string $b): bool
*/
public function makeRelative(AbsolutePathInterface $targetPath, ?\Closure $equals = null): RelativePathInterface
{
@ -24,34 +32,52 @@ abstract class AbstractAbsolutePath extends AbstractPath implements AbsolutePath
// optimize if the same instance
if ($this === $targetPath || $this->components === $targetPath->components) {
return $this->buildRelative(DataTypeHelper::iterableToNewListInstance(['.']));
return $this->buildRelative(DataTypeHelper::iterableToNewListInstance(
$this->components->count() > 0 && $this->components->top() === '' ? ['.', ''] : ['.']
));
}
$length = min($this->components->count(), $targetPath->components->count());
// strip trailing slash
$baseComponents = $this->components;
if ($baseComponents->count() > 0 && $baseComponents->top() === '') {
$baseComponents = clone $baseComponents; // clone when necessary
$baseComponents->pop();
}
// strip trailing slash
$targetComponents = clone $targetPath->components; // always clone
if ($targetComponents->count() > 0 && $targetComponents->top() === '') {
$targetComponents->pop();
}
$length = min($baseComponents->count(), $targetComponents->count());
$equals ??= fn($a, $b) => $a === $b;
for ($i = 0; $i < $length; $i++) {
if (!$equals($this->components[$i], $targetPath->components[$i])) {
if (!$equals($baseComponents[$i], $targetComponents[$i])) {
break;
}
}
$relativeComponents = clone $targetPath->components;
// delete $i components from the beginning (common prefix)
for ($j = 0; $j < $i; $j++) {
$relativeComponents->shift();
$targetComponents->shift();
}
// add (baseLen - $i) .. elements
$numBaseDiff = $this->components->count() - $i;
$numBaseDiff = $baseComponents->count() - $i;
for ($j = 0; $j < $numBaseDiff; $j++) {
$relativeComponents->unshift('..');
$targetComponents->unshift('..');
}
$relativeComponents->unshift('.');
// relative marker
$targetComponents->unshift('.');
// trailing slash
if ($targetPath->components->count() && $targetPath->components->top() === '') {
$targetComponents->push('');
}
return $this->buildRelative($relativeComponents);
return $this->buildRelative($targetComponents);
}
protected function buildRelative(\SplDoublyLinkedList $components): RelativePathInterface

@ -6,9 +6,6 @@ namespace Arokettu\Path;
use Arokettu\Path\Helpers\DataTypeHelper;
/**
* @internal
*/
abstract class AbstractPath implements PathInterface
{
protected string $prefix;
@ -48,6 +45,11 @@ abstract class AbstractPath implements PathInterface
$components = clone $this->components;
// remove trailing slash
if ($components->top() === '') {
$components->pop();
}
$numComponents = \count($relativeComponents);
for ($i = 0; $i < $numComponents; $i++) {
if ($relativeComponents[$i] === '.') {
@ -75,10 +77,22 @@ abstract class AbstractPath implements PathInterface
protected function normalize(array $components): \SplDoublyLinkedList
{
$numComponents = \count($components);
// skip empties in the beginning
for ($i = 0; $i < $numComponents; $i++) {
if ($components[$i] !== '') {
break;
}
}
$componentsList = new \SplDoublyLinkedList();
$component = null; // also stores last component ignoring $prevComponent logic
$prevComponent = null;
foreach ($components as $component) {
for ($j = $i; $j < $numComponents; $j++) {
$component = $components[$j];
if ($component === '.' || $component === '') {
continue;
}
@ -96,6 +110,11 @@ abstract class AbstractPath implements PathInterface
$prevComponent = $component;
}
// trailing slash logic
if ($component === '') {
$componentsList->push('');
}
return $componentsList;
}

@ -6,16 +6,6 @@ namespace Arokettu\Path;
abstract class FilesystemPath extends AbstractAbsolutePath
{
public function __construct(string $path, bool $strict = false)
{
if ($this instanceof WindowsPath || $this instanceof UnixPath) {
parent::__construct($path, $strict);
return;
}
throw new \LogicException('The class is not meant to be extended externally');
}
/**
* @codeCoverageIgnore OS specific
*/

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path;
final class PathFactory
{
public static function parse(string $path, array $urlSchemes = [], array $streamSchemes = []): PathInterface
{
if ($path[0] === '/') {
return UnixPath::parse($path);
}
if (preg_match('@^[a-zA-Z]:[\\\\/]@', $path) || str_starts_with($path, '\\\\')) {
return new WindowsPath($path);
}
if (preg_match('@^([-.+a-zA-Z0-9]+)://@', $path, $matches)) {
return self::parseUrlLike($path, $matches[1], $urlSchemes, $streamSchemes);
}
return RelativePath::currentOS($path);
}
private static function parseUrlLike(
string $path,
string $scheme,
array $urlSchemes = [],
array $streamSchemes = []
): PathInterface {
if ($urlSchemes === [] && $streamSchemes === []) {
return UrlPath::parse($path);
}
if (\in_array($scheme, $urlSchemes)) {
return UrlPath::parse($path);
}
if (\in_array($scheme, $streamSchemes)) {
return StreamPath::parse($path);
}
throw new \InvalidArgumentException('Unknown scheme: ' . $scheme);
}
}

@ -6,6 +6,9 @@ namespace Arokettu\Path;
interface PathInterface extends \Stringable
{
public function isAbsolute(): bool;
public function isRelative(): bool;
public function getPrefix(): string;
public function getComponents(): array;
public function toString(): string;

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path;
final class PathUtils
{
/**
* @param string|PathInterface $basePath
* @param string|PathInterface $relativePath
* @return string
*/
public static function resolveRelativePath($basePath, $relativePath): string
{
if (\is_string($basePath)) {
$basePath = PathFactory::parse($basePath);
}
if (\is_string($relativePath)) {
$relativePath = PathFactory::parse($relativePath);
}
if (!($basePath instanceof PathInterface)) {
throw new \InvalidArgumentException('basePath must be a string or an instance of PathInterface');
}
if ($relativePath instanceof RelativePathInterface) {
return $basePath->resolveRelative($relativePath)->toString();
} elseif ($relativePath instanceof AbsolutePathInterface) {
return $relativePath->toString();
}
throw new \InvalidArgumentException('relativePath must be a string or an instance of PathInterface');
}
/**
* @param string|AbsolutePathInterface $basePath
* @param string|AbsolutePathInterface $targetPath
* @return string
*/
public static function makeRelativePath($basePath, $targetPath): string
{
if (\is_string($basePath)) {
$basePath = PathFactory::parse($basePath);
}
if (\is_string($targetPath)) {
$targetPath = PathFactory::parse($targetPath);
}
if (!($basePath instanceof AbsolutePathInterface) || !$basePath->isAbsolute()) {
throw new \InvalidArgumentException(
'basePath must be a string containing absolute path or an instance of AbsolutePathInterface'
);
}
if (!($targetPath instanceof AbsolutePathInterface) || !$targetPath->isAbsolute()) {
throw new \InvalidArgumentException(
'targetPath must be a string containing absolute path or an instance of AbsolutePathInterface'
);
}
return $basePath->makeRelative($targetPath)->toString();
}
}

@ -33,6 +33,24 @@ final class RelativePath extends AbstractPath implements RelativePathInterface
return new self($path, DIRECTORY_SEPARATOR === '\\');
}
/**
* @codeCoverageIgnore OS specific
*/
public static function parse(string $path): self
{
return self::currentOS($path);
}
public function isAbsolute(): bool
{
return false;
}
public function isRelative(): bool
{
return true;
}
protected function parsePath(string $path, bool $strict): void
{
$components = explode('/', $path);
@ -48,7 +66,7 @@ final class RelativePath extends AbstractPath implements RelativePathInterface
$parsedComponents = $this->normalize($components);
// absolute-ish relative path
$isRoot = $path[0] === '/' || $this->windows && $path[0] === '\\';
$isRoot = \strlen($path) > 0 && ($path[0] === '/' || $this->windows && $path[0] === '\\');
if (!$isRoot) {
$parsedComponents->unshift('.');
}
@ -59,7 +77,7 @@ final class RelativePath extends AbstractPath implements RelativePathInterface
public function isRoot(): bool
{
return $this->components[0] !== '.' && $this->components[0] !== '..';
return $this->components->count() === 0 || $this->components[0] !== '.' && $this->components[0] !== '..';
}
public function toString(): string
@ -67,7 +85,7 @@ final class RelativePath extends AbstractPath implements RelativePathInterface
$directorySeparator = $this->windows ? '\\' : '/';
$components = $this->components;
if ($components[0] === '.' && $components->count() > 1) {
if ($components->count() > 1 && $components[0] === '.' && $components[1] !== '') {
$components = clone $components;
$components->shift();
}

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path;
final class StreamPath extends AbstractAbsolutePath
{
public static function parse(string $path, bool $strict = false): self
{
return new self($path, $strict);
}
protected function parsePath(string $path, bool $strict): void
{
if (!preg_match('@^[-.+a-zA-Z0-9]+://@', $path, $matches)) {
throw new \InvalidArgumentException('The path does not appear to be a PHP stream path');
}
$prefix = $matches[0];
$restOfPath = substr($path, \strlen($prefix));
$components = explode('/', $restOfPath);
$parsedComponents = $this->normalize($components);
if ($parsedComponents->count() > 0 && $parsedComponents[0] === '..') {
if ($strict) {
throw new \InvalidArgumentException('Path went beyond root');
}
do {
$parsedComponents->shift();
} while ($parsedComponents[0] === '..');
}
$this->prefix = $prefix;
$this->components = $parsedComponents;
}
}

@ -21,7 +21,7 @@ final class UnixPath extends FilesystemPath
$parsedComponents = $this->normalize($components);
if ($parsedComponents[0] === '..') {
if ($parsedComponents->count() > 0 && $parsedComponents[0] === '..') {
if ($strict) {
throw new \InvalidArgumentException('Path went beyond root');
}

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path;
final class UrlPath extends AbstractAbsolutePath
{
public static function parse(string $path, bool $strict = false): self
{
return new self($path, $strict);
}
protected function parsePath(string $path, bool $strict): void
{
$urlComponents = parse_url($path);
if ($urlComponents === false) {
throw new \InvalidArgumentException('Url is malformed');
}
$urlPath = $urlComponents['path'] ?? '';
$prefix = '';
if (isset($urlComponents['scheme'])) {
$prefix .= $urlComponents['scheme'] . ':';
}
if (isset($urlComponents['host'])) {
$prefix .= '//';
if (isset($urlComponents['user'])) {
$prefix .= $urlComponents['user'];
if (isset($urlComponents['pass'])) {
$prefix .= ':' . $urlComponents['pass'];
}
$prefix .= '@';
}
$prefix .= $urlComponents['host'] . '/';
}
$components = explode('/', $urlPath);
$parsedComponents = $this->normalize($components);
if ($parsedComponents->count() > 0 && $parsedComponents[0] === '..') {
if ($strict) {
throw new \InvalidArgumentException('Path went beyond root');
}
do {
$parsedComponents->shift();
} while ($parsedComponents[0] === '..');
}
$this->prefix = $prefix;
$this->components = $parsedComponents;
}
}

@ -55,7 +55,7 @@ final class WindowsPath extends FilesystemPath
$parsedComponents = $this->normalize($components);
if ($parsedComponents[0] === '..') {
if ($parsedComponents->count() > 0 && $parsedComponents[0] === '..') {
if ($strict) {
throw new \InvalidArgumentException('Path went beyond root');
}

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path\Tests\Classes;
use Arokettu\Path\PathInterface;
use Arokettu\Path\RelativePathInterface;
class CustomRelativePathImplementation implements RelativePathInterface
{
private array $components;
private bool $isRoot;
public function __construct(array $components, bool $isRoot)
{
$this->components = $components;
$this->isRoot = $isRoot;
}
public function isAbsolute(): bool
{
return false;
}
public function isRelative(): bool
{
return true;
}
public function __toString(): string
{
return '';
}
public function getPrefix(): string
{
return '';
}
public function getComponents(): array
{
return $this->components;
}
public function toString(): string
{
throw new \BadMethodCallException('Irrelevant');
}
public function resolveRelative(RelativePathInterface $path, bool $strict = false): PathInterface
{
throw new \BadMethodCallException('Irrelevant');
}
public function isRoot(): bool
{
return $this->isRoot;
}
}

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path\Tests;
use Arokettu\Path\FilesystemPath;
use PHPUnit\Framework\TestCase;
class FilesystemPathTest extends TestCase
{
public function testDoNotAllowExtending(): void
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('The class is not meant to be extended externally');
new class ('') extends FilesystemPath {
protected function parsePath(string $path, bool $strict): void
{
// whatever
}
};
}
}

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path\Tests;
use Arokettu\Path\PathFactory;
use Arokettu\Path\RelativePath;
use Arokettu\Path\StreamPath;
use Arokettu\Path\UnixPath;
use Arokettu\Path\UrlPath;
use Arokettu\Path\WindowsPath;
use PHPUnit\Framework\TestCase;
class PathFactoryTest extends TestCase
{
public function testValid(): void
{
self::assertInstanceOf(UnixPath::class, PathFactory::parse('/unix/path'));
self::assertInstanceOf(WindowsPath::class, PathFactory::parse('C:/unixlike/path'));
self::assertInstanceOf(WindowsPath::class, PathFactory::parse('c:\win\path'));
self::assertInstanceOf(WindowsPath::class, PathFactory::parse('\\\\unc\path'));
self::assertInstanceOf(RelativePath::class, PathFactory::parse('../test/path'));
self::assertInstanceOf(RelativePath::class, PathFactory::parse('./test/path'));
self::assertInstanceOf(RelativePath::class, PathFactory::parse('test/path'));
}
public function testValidUrls(): void
{
self::assertInstanceOf(UrlPath::class, PathFactory::parse('https://example.com/test/test'));
self::assertInstanceOf(UrlPath::class, PathFactory::parse('vfs://test/test'));
$urlSchemes = ['http', 'https', 'ftp'];
$streamSchemes = ['vfs', 'php'];
self::assertInstanceOf(
UrlPath::class,
PathFactory::parse('https://example.com/test/test', $urlSchemes, $streamSchemes)
);
self::assertInstanceOf(
StreamPath::class,
PathFactory::parse('vfs://test/test', $urlSchemes, $streamSchemes)
);
}
public function testUnknownScheme(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unknown scheme: unk');
$urlSchemes = ['http', 'https', 'ftp'];
$streamSchemes = ['vfs', 'php'];
PathFactory::parse('unk://test/test', $urlSchemes, $streamSchemes);
}
}

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path\Tests;
use Arokettu\Path\PathUtils;
use PHPUnit\Framework\TestCase;
class PathUtilsTest extends TestCase
{
public function testMakeRelative(): void
{
self::assertEquals(
'../../.config/composer',
PathUtils::makeRelativePath(
'/home/arokettu/tmp/test',
'/home/arokettu/.config/composer',
),
);
self::assertEquals(
'..\..\AppData\Roaming',
PathUtils::makeRelativePath(
'C:\Users\Arokettu\tmp\test',
'C:\Users\Arokettu\AppData\Roaming',
),
);
}
public function testResolveRelative(): void
{
// any, absolute
self::assertEquals(
'/home/arokettu/.config/composer',
PathUtils::resolveRelativePath(
'/home/arokettu/tmp/test',
'/home/arokettu/.config/composer',
),
);
// absolute, relative
self::assertEquals(
'/home/arokettu/.config/composer',
PathUtils::resolveRelativePath(
'/home/arokettu/tmp/test',
'../../.config/composer',
),
);
// relative, relative
self::assertEquals(
'.config/composer',
PathUtils::resolveRelativePath(
'./tmp/test',
'../../.config/composer',
),
);
}
}

@ -30,6 +30,24 @@ class RelativePathTest extends TestCase
$path = RelativePath::unix('.');
self::assertEquals('.', $path->toString());
// test empty
$path = RelativePath::unix('');
self::assertEquals('.', $path->toString());
// preserve trailing slash
$path = RelativePath::unix('./');
self::assertEquals('./', $path->toString());
$path = RelativePath::unix('../');
self::assertEquals('../', $path->toString());
$path = RelativePath::unix('path/');
self::assertEquals('path/', $path->toString());
// root path
$path = RelativePath::unix('/');
self::assertEquals('/', $path->toString());
}
public function testCreateWindows(): void
@ -57,7 +75,6 @@ class RelativePathTest extends TestCase
new RelativePath('..'),
new RelativePath('.'),
];
$relativePaths = $paths;
$matrix = [
[
@ -111,7 +128,7 @@ class RelativePathTest extends TestCase
];
foreach ($paths as $pi => $p) {
foreach ($relativePaths as $rpi => $rp) {
foreach ($paths as $rpi => $rp) {
$matrixResult = $matrix[$pi][$rpi];
self::assertEquals($matrixResult, $p->resolveRelative($rp)->toString());
@ -127,7 +144,6 @@ class RelativePathTest extends TestCase
new RelativePath('../../i/am/test/relative/path'),
new RelativePath('../../../../../../../../i/am/test/relative/path'),
];
$relativePaths = $paths;
$matrix = [
[
@ -157,7 +173,7 @@ class RelativePathTest extends TestCase
];
foreach ($paths as $pi => $p) {
foreach ($relativePaths as $rpi => $rp) {
foreach ($paths as $rpi => $rp) {
$matrixResult = $matrix[$pi][$rpi];
if ($matrixResult === null) {
@ -169,6 +185,51 @@ class RelativePathTest extends TestCase
}
}
public function testResolveRelativeTrailingSlash(): void
{
$paths = [
new RelativePath('../path/path1'),
new RelativePath('../path/path1/'),
new RelativePath('../path/path2'),
new RelativePath('../path/path2/'),
];
$matrix = [
[
'../path/path/path1',
'../path/path/path1/',
'../path/path/path2',
'../path/path/path2/',
],
[
'../path/path/path1',
'../path/path/path1/',
'../path/path/path2',
'../path/path/path2/',
],
[
'../path/path/path1',
'../path/path/path1/',
'../path/path/path2',
'../path/path/path2/',
],
[
'../path/path/path1',
'../path/path/path1/',
'../path/path/path2',
'../path/path/path2/',
],
];
foreach ($paths as $pi => $p) {
foreach ($paths as $rpi => $rp) {
$matrixResult = $matrix[$pi][$rpi];
self::assertEquals($matrixResult, $p->resolveRelative($rp)->toString());
}
}
}
public function testResolveRelativeStrictAssertion(): void
{
$this->expectException(\InvalidArgumentException::class);
@ -184,68 +245,15 @@ class RelativePathTest extends TestCase
{
$p = new RelativePath('i/am/test/relative/path');
$rp1 = new class implements RelativePathInterface {
public function __toString(): string {
return '';
}
$rp1 = new Classes\CustomRelativePathImplementation(
explode('/', '../../i/am/test/relative/path'),