Compare commits

...

29 Commits

Author SHA1 Message Date
Anton Smirnov bbef65aff7 Stable standard 3 months ago
Anton Smirnov 976596f4f0 .gitattributes from tpl 3 months ago
Anton Smirnov 90e26f935e Test in 8.2 6 months ago
Anton Smirnov 21956c0788 Allow plugins 9 months ago
Anton Smirnov 624ff1fa26 Update phpunit config 11 months ago
Anton Smirnov 02acd99856 Bump PHP unit requirement 2 years ago
Anton Smirnov 9b7d03c546 Fix VCS paths 2 years ago
Anton Smirnov c512bc3114 Changelog for 1.0.0 2 years ago
Anton Smirnov 0283f79c7b Helper classes doc 2 years ago
Anton Smirnov 844d094d49 Docs for path classes 2 years ago
Anton Smirnov ca67c164fe Fix title 2 years ago
Anton Smirnov a49fd421d0 README, LICENSE, CHANGELOG 2 years ago
Anton Smirnov b70f1fb0de Add homepage and docs 2 years ago
Anton Smirnov 3279992be7 Interfaces page 2 years ago
Anton Smirnov f2e6216765 Add parse for completeness 2 years ago
Anton Smirnov bbddefc0de phpdoc for callable 2 years ago
Anton Smirnov deb4524b8f Docs stub 2 years ago
Anton Smirnov 9e438c2a2c GitLab CI 2 years ago
Anton Smirnov dce3a58477 test PathUtils 2 years ago
Anton Smirnov 58fe768a21 PathUtils 2 years ago
Anton Smirnov f3f2827534 Path factory 2 years ago
Anton Smirnov e581028134 Stream path 2 years ago
Anton Smirnov 90d6a2acc4 Url path 2 years ago
Anton Smirnov 1677908239 Trailing slash and root path in makeRelative 2 years ago
Anton Smirnov 90db790146 * trailing slash preservation logic
* test root paths
2 years ago
Anton Smirnov f9c6cd5340 Fix tests 2 years ago
Anton Smirnov 04b521b288 Remove inheritance check 2 years ago
Anton Smirnov f1f898eecf isAbsolute / isRelative 2 years ago
Anton Smirnov d1b917c340 Psalm doesn't like these @internals 2 years ago

12
.gitattributes vendored

@ -1,4 +1,8 @@
/tests export-ignore
/.git* export-ignore
/*.yml export-ignore
/*.xml export-ignore
/docs/*.py export-ignore
/docs/*.txt export-ignore
/docs/_* export-ignore
/tests export-ignore
/.git* export-ignore
/*.yml export-ignore
/*.xml export-ignore
/*.xml.dist export-ignore

3
.gitignore vendored

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

@ -0,0 +1,63 @@
stages:
- test
- report
cache:
key: composer-cache
paths:
- .composer-cache/
.test:
before_script:
- php -v
# 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
## latest 8
test-8:
extends: .test
stage: test
image: php:8
# 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,9 +15,13 @@
],
"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": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"sort-packages": true
},
"autoload": {
@ -36,9 +41,9 @@
"symfony/polyfill-php81": "^1.22"
},
"require-dev": {
"phpunit/phpunit": ">= 7 < 10",
"phpunit/phpunit": "^9.5",
"psy/psysh": "*",
"sandfox.dev/code-standard": "^10@dev",
"sandfox.dev/code-standard": "^1",
"squizlabs/php_codesniffer": "*",
"vimeo/psalm": "^4.11"
}

@ -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'),
false,
);
public function getPrefix(): string
{
return '';
}
public function getComponents(): array
{
return explode('/', '../../i/am/test/relative/path');
}
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 false;
}
};
$rp2 = new class implements RelativePathInterface {
public function __toString(): string
{
return '';
}
public function getPrefix(): string
{
return '';
}
public function getComponents(): array
{
return explode('/', 'i/am/test/relative/path');
}
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 true;
}
};
$rp2 = new Classes\CustomRelativePathImplementation(
explode('/', 'i/am/test/relative/path'),
true,
);
self::assertEquals('i/am/test/i/am/test/relative/path', $p->resolveRelative($rp1)->toString());
self::assertEquals('/i/am/test/relative/path', $p->resolveRelative($rp2)->toString());

@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace Arokettu\Path\Tests;
use Arokettu\Path\RelativePath;
use Arokettu\Path\StreamPath;
use Arokettu\Path\UnixPath;
use PHPUnit\Framework\TestCase;
class StreamPathTest extends TestCase
{
public function testCreate(): void
{
$path = StreamPath::parse('vfs://i/./am/./skipme/./.././test/./unix/path');
self::assertEquals('vfs://i/am/test/unix/path', $path->toString());
$path = StreamPath::parse('vfs://invalid/level/of/nesting/../../../../../../../../../../i/am/test/unix/path');
self::assertEquals('vfs://i/am/test/unix/path', $path->toString());
$path = StreamPath::parse('vfs://i/./am/./skipme/./.././test/./unix/path', true);
self::assertEquals('vfs://i/am/test/unix/path', $path->toString());
// root path
$path = StreamPath::parse('vfs://', true);
self::assertEquals('vfs://', $path->toString());
}
public function testCreateStrict(): void
{