Dustin Wilson
1 year ago
commit
4629311199
9 changed files with 1733 additions and 0 deletions
@ -0,0 +1,75 @@ |
|||
# Project-specific |
|||
/test*.* |
|||
/build |
|||
|
|||
# General |
|||
*.DS_Store |
|||
.AppleDouble |
|||
.LSOverride |
|||
|
|||
# Icon must end with two \r |
|||
Icon |
|||
|
|||
|
|||
# Thumbnails |
|||
._* |
|||
|
|||
# Files that might appear in the root of a volume |
|||
.DocumentRevisions-V100 |
|||
.fseventsd |
|||
.Spotlight-V100 |
|||
.TemporaryItems |
|||
.Trashes |
|||
.VolumeIcon.icns |
|||
.com.apple.timemachine.donotpresent |
|||
|
|||
# Directories potentially created on remote AFP share |
|||
.AppleDB |
|||
.AppleDesktop |
|||
Network Trash Folder |
|||
Temporary Items |
|||
.apdisk |
|||
|
|||
# Windows thumbnail cache files |
|||
Thumbs.db |
|||
ehthumbs.db |
|||
ehthumbs_vista.db |
|||
|
|||
# Dump file |
|||
*.stackdump |
|||
|
|||
# Folder config file |
|||
Desktop.ini |
|||
|
|||
# Recycle Bin used on file shares |
|||
$RECYCLE.BIN/ |
|||
|
|||
# Windows Installer files |
|||
*.cab |
|||
*.msi |
|||
*.msm |
|||
*.msp |
|||
|
|||
# Windows shortcuts |
|||
*.lnk |
|||
|
|||
*~ |
|||
|
|||
# temporary files which can be created if a process still has a handle open of a deleted file |
|||
.fuse_hidden* |
|||
|
|||
# KDE directory preferences |
|||
.directory |
|||
|
|||
# Linux trash folder which might appear on any partition or disk |
|||
.Trash-* |
|||
|
|||
# .nfs files are created when an open file is removed but is still being accessed |
|||
.nfs* |
|||
|
|||
/vendor/ |
|||
/vendor-bin/*/vendor |
|||
/tests/html5lib-tests |
|||
/tests/.phpunit.result.cache |
|||
/tests/coverage |
|||
cachegrind.out.* |
@ -0,0 +1,4 @@ |
|||
Project leads |
|||
------------- |
|||
Dustin Wilson https://dustinwilson.com/ |
|||
J. King https://jkingweb.ca/ |
@ -0,0 +1,23 @@ |
|||
Copyright (c) 2023 Dustin Wilson, J. King |
|||
Original copyright (c) 2004 Fabien Potencier |
|||
|
|||
Permission is hereby granted, free of charge, to any person |
|||
obtaining a copy of this software and associated documentation |
|||
files (the "Software"), to deal in the Software without |
|||
restriction, including without limitation the rights to use, |
|||
copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the |
|||
Software is furnished to do so, subject to the following |
|||
conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be |
|||
included in all copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES |
|||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
|||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, |
|||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
|||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR |
|||
OTHER DEALINGS IN THE SOFTWARE. |
@ -0,0 +1,35 @@ |
|||
# Filesystem # |
|||
|
|||
This is a fork of Symfony's Filesystem component which simplifies php's built-in filesystem functions. A common painpoint in using Symfony's component is that it is unnecessarily instantiated: |
|||
|
|||
```php |
|||
use Symfony\Component\Filesystem; |
|||
$fs = new Filesystem(); |
|||
$fs->chmod('/path/to/file', 0600); |
|||
``` |
|||
|
|||
This is awkward because there isn't a reason to instantiate it. There's nothing within Filesystem to create an instance of. There's no defined constructor and no properties to set. In fact only a single static property exists within the class to store the last encountered error. It simply doesn't make any sense. It's especially curious considering the `Path` class that's included with `Filesystem` is itself a static class. |
|||
|
|||
This fork eliminates that nonsense by making everything static: |
|||
|
|||
```php |
|||
use MensBeam\Filesystem as Fs; |
|||
Fs::chmod('/path/to/file', 0600); |
|||
``` |
|||
|
|||
## Note ## |
|||
|
|||
This library uses polyfills for `ext-ctype` and `ext-mbstring`. If you have these extensions installed the polyfills won't run. However, if you don't want the polyfills needlessly installed you can do this in your `composer.json`: |
|||
|
|||
```json |
|||
{ |
|||
"require": { |
|||
"ext-ctype": "*", |
|||
"ext-mbstring": "*" |
|||
}, |
|||
"provide": { |
|||
"symfony/polyfill-ctype": "*", |
|||
"symfony/polyfill-mbstring": "*" |
|||
} |
|||
} |
|||
``` |
@ -0,0 +1,29 @@ |
|||
{ |
|||
"name": "mensbeam/filesystem", |
|||
"description": "Simplifies using many of php's built-in filesystem functions", |
|||
"type": "library", |
|||
"license": "MIT", |
|||
"autoload": { |
|||
"psr-4": { |
|||
"MensBeam\\": "lib/" |
|||
} |
|||
}, |
|||
"authors": [ |
|||
{ |
|||
"name": "Dustin Wilson", |
|||
"email": "dustin@dustinwilson.com" |
|||
} |
|||
], |
|||
"require": { |
|||
"php": ">=8.1", |
|||
"symfony/polyfill-ctype": ">=1.8", |
|||
"symfony/polyfill-mbstring": ">=1.8" |
|||
}, |
|||
"require-dev": { |
|||
"symfony/filesystem": ">=6.2" |
|||
}, |
|||
"suggest": { |
|||
"ext-ctype": "For better performance", |
|||
"ext-mbstring": "For better performance" |
|||
} |
|||
} |
@ -0,0 +1,712 @@ |
|||
<?php |
|||
/** |
|||
* @license MIT |
|||
* Copyright 2023 Dustin Wilson, J. King, et al. |
|||
* Original copyright 2023 Fabien Potencier |
|||
* See LICENSE and AUTHORS files for details |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
namespace MensBeam; |
|||
use MensBeam\Filesystem\{ |
|||
FileNotFoundException, |
|||
IOException |
|||
}; |
|||
|
|||
|
|||
/** |
|||
* Provides basic utility to manipulate the file system. |
|||
* |
|||
* @author Fabien Potencier <fabien@symfony.com> |
|||
*/ |
|||
class Filesystem { |
|||
private static $lastError; |
|||
|
|||
/** |
|||
* Copies a file. |
|||
* |
|||
* If the target file is older than the origin file, it's always overwritten. |
|||
* If the target file is newer, it is overwritten only when the |
|||
* $overwriteNewerFiles option is set to true. |
|||
* |
|||
* @throws FileNotFoundException When originFile doesn't exist |
|||
* @throws IOException When copy fails |
|||
*/ |
|||
public static function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false) { |
|||
$originIsLocal = stream_is_local($originFile) || 0 === stripos($originFile, 'file://'); |
|||
if ($originIsLocal && !is_file($originFile)) { |
|||
throw new FileNotFoundException(sprintf('Failed to copy "%s" because file does not exist.', $originFile), 0, null, $originFile); |
|||
} |
|||
|
|||
self::mkdir(\dirname($targetFile)); |
|||
|
|||
$doCopy = true; |
|||
if (!$overwriteNewerFiles && null === parse_url($originFile, \PHP_URL_HOST) && is_file($targetFile)) { |
|||
$doCopy = filemtime($originFile) > filemtime($targetFile); |
|||
} |
|||
|
|||
if ($doCopy) { |
|||
// https://bugs.php.net/64634 |
|||
if (!$source = self::box('fopen', $originFile, 'r')) { |
|||
throw new IOException(sprintf('Failed to copy "%s" to "%s" because source file could not be opened for reading: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); |
|||
} |
|||
|
|||
// Stream context created to allow files overwrite when using FTP stream wrapper - disabled by default |
|||
if (!$target = self::box('fopen', $targetFile, 'w', false, stream_context_create(['ftp' => ['overwrite' => true]]))) { |
|||
throw new IOException(sprintf('Failed to copy "%s" to "%s" because target file could not be opened for writing: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); |
|||
} |
|||
|
|||
$bytesCopied = stream_copy_to_stream($source, $target); |
|||
fclose($source); |
|||
fclose($target); |
|||
unset($source, $target); |
|||
|
|||
if (!is_file($targetFile)) { |
|||
throw new IOException(sprintf('Failed to copy "%s" to "%s".', $originFile, $targetFile), 0, null, $originFile); |
|||
} |
|||
|
|||
if ($originIsLocal) { |
|||
// Like `cp`, preserve executable permission bits |
|||
self::box('chmod', $targetFile, fileperms($targetFile) | (fileperms($originFile) & 0111)); |
|||
|
|||
if ($bytesCopied !== $bytesOrigin = filesize($originFile)) { |
|||
throw new IOException(sprintf('Failed to copy the whole content of "%s" to "%s" (%g of %g bytes copied).', $originFile, $targetFile, $bytesCopied, $bytesOrigin), 0, null, $originFile); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Creates a directory recursively. |
|||
* |
|||
* @throws IOException On any directory creation failure |
|||
*/ |
|||
public static function mkdir(string|iterable $dirs, int $mode = 0777) { |
|||
foreach (self::toIterable($dirs) as $dir) { |
|||
if (is_dir($dir)) { |
|||
continue; |
|||
} |
|||
|
|||
if (!self::box('mkdir', $dir, $mode, true) && !is_dir($dir)) { |
|||
throw new IOException(sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Checks the existence of files or directories. |
|||
*/ |
|||
public static function exists(string|iterable $files): bool { |
|||
$maxPathLength = \PHP_MAXPATHLEN - 2; |
|||
|
|||
foreach (self::toIterable($files) as $file) { |
|||
if (\strlen($file) > $maxPathLength) { |
|||
throw new IOException(sprintf('Could not check if file exist because path length exceeds %d characters.', $maxPathLength), 0, null, $file); |
|||
} |
|||
|
|||
if (!file_exists($file)) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/** |
|||
* Sets access and modification time of file. |
|||
* |
|||
* @param int|null $time The touch time as a Unix timestamp, if not supplied the current system time is used |
|||
* @param int|null $atime The access time as a Unix timestamp, if not supplied the current system time is used |
|||
* |
|||
* @throws IOException When touch fails |
|||
*/ |
|||
public static function touch(string|iterable $files, int $time = null, int $atime = null) { |
|||
foreach (self::toIterable($files) as $file) { |
|||
if (!($time ? self::box('touch', $file, $time, $atime) : self::box('touch', $file))) { |
|||
throw new IOException(sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Removes files or directories. |
|||
* |
|||
* @throws IOException When removal fails |
|||
*/ |
|||
public static function remove(string|iterable $files) { |
|||
if ($files instanceof \Traversable) { |
|||
$files = iterator_to_array($files, false); |
|||
} elseif (!\is_array($files)) { |
|||
$files = [$files]; |
|||
} |
|||
|
|||
self::doRemove($files, false); |
|||
} |
|||
|
|||
private static function doRemove(array $files, bool $isRecursive): void { |
|||
$files = array_reverse($files); |
|||
foreach ($files as $file) { |
|||
if (is_link($file)) { |
|||
// See https://bugs.php.net/52176 |
|||
if (!(self::box('unlink', $file) || '\\' !== \DIRECTORY_SEPARATOR || self::box('rmdir', $file)) && file_exists($file)) { |
|||
throw new IOException(sprintf('Failed to remove symlink "%s": ', $file).self::$lastError); |
|||
} |
|||
} elseif (is_dir($file)) { |
|||
if (!$isRecursive) { |
|||
$tmpName = \dirname(realpath($file)).'/.'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-.')); |
|||
|
|||
if (file_exists($tmpName)) { |
|||
try { |
|||
self::doRemove([$tmpName], true); |
|||
} catch (IOException) { |
|||
} |
|||
} |
|||
|
|||
if (!file_exists($tmpName) && self::box('rename', $file, $tmpName)) { |
|||
$origFile = $file; |
|||
$file = $tmpName; |
|||
} else { |
|||
$origFile = null; |
|||
} |
|||
} |
|||
|
|||
$filesystemIterator = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); |
|||
self::doRemove(iterator_to_array($filesystemIterator, true), true); |
|||
|
|||
if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { |
|||
$lastError = self::$lastError; |
|||
|
|||
if (null !== $origFile && self::box('rename', $file, $origFile)) { |
|||
$file = $origFile; |
|||
} |
|||
|
|||
throw new IOException(sprintf('Failed to remove directory "%s": ', $file).$lastError); |
|||
} |
|||
} elseif (!self::box('unlink', $file) && (str_contains(self::$lastError, 'Permission denied') || file_exists($file))) { |
|||
throw new IOException(sprintf('Failed to remove file "%s": ', $file).self::$lastError); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Change mode for an array of files or directories. |
|||
* |
|||
* @param int $mode The new mode (octal) |
|||
* @param int $umask The mode mask (octal) |
|||
* @param bool $recursive Whether change the mod recursively or not |
|||
* |
|||
* @throws IOException When the change fails |
|||
*/ |
|||
public static function chmod(string|iterable $files, int $mode, int $umask = 0000, bool $recursive = false) { |
|||
foreach (self::toIterable($files) as $file) { |
|||
if (\is_int($mode) && !self::box('chmod', $file, $mode & ~$umask)) { |
|||
throw new IOException(sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file); |
|||
} |
|||
if ($recursive && is_dir($file) && !is_link($file)) { |
|||
self::chmod(new \FilesystemIterator($file), $mode, $umask, true); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Change the owner of an array of files or directories. |
|||
* |
|||
* @param string|int $user A user name or number |
|||
* @param bool $recursive Whether change the owner recursively or not |
|||
* |
|||
* @throws IOException When the change fails |
|||
*/ |
|||
public static function chown(string|iterable $files, string|int $user, bool $recursive = false) { |
|||
foreach (self::toIterable($files) as $file) { |
|||
if ($recursive && is_dir($file) && !is_link($file)) { |
|||
self::chown(new \FilesystemIterator($file), $user, true); |
|||
} |
|||
if (is_link($file) && \function_exists('lchown')) { |
|||
if (!self::box('lchown', $file, $user)) { |
|||
throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); |
|||
} |
|||
} else { |
|||
if (!self::box('chown', $file, $user)) { |
|||
throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Change the group of an array of files or directories. |
|||
* |
|||
* @param string|int $group A group name or number |
|||
* @param bool $recursive Whether change the group recursively or not |
|||
* |
|||
* @throws IOException When the change fails |
|||
*/ |
|||
public static function chgrp(string|iterable $files, string|int $group, bool $recursive = false) { |
|||
foreach (self::toIterable($files) as $file) { |
|||
if ($recursive && is_dir($file) && !is_link($file)) { |
|||
self::chgrp(new \FilesystemIterator($file), $group, true); |
|||
} |
|||
if (is_link($file) && \function_exists('lchgrp')) { |
|||
if (!self::box('lchgrp', $file, $group)) { |
|||
throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); |
|||
} |
|||
} else { |
|||
if (!self::box('chgrp', $file, $group)) { |
|||
throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Renames a file or a directory. |
|||
* |
|||
* @throws IOException When target file or directory already exists |
|||
* @throws IOException When origin cannot be renamed |
|||
*/ |
|||
public static function rename(string $origin, string $target, bool $overwrite = false) { |
|||
// we check that target does not exist |
|||
if (!$overwrite && self::isReadable($target)) { |
|||
throw new IOException(sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target); |
|||
} |
|||
|
|||
if (!self::box('rename', $origin, $target)) { |
|||
if (is_dir($origin)) { |
|||
// See https://bugs.php.net/54097 & https://php.net/rename#113943 |
|||
self::mirror($origin, $target, null, ['override' => $overwrite, 'delete' => $overwrite]); |
|||
self::remove($origin); |
|||
|
|||
return; |
|||
} |
|||
throw new IOException(sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Tells whether a file exists and is readable. |
|||
* |
|||
* @throws IOException When windows path is longer than 258 characters |
|||
*/ |
|||
private static function isReadable(string $filename): bool { |
|||
$maxPathLength = \PHP_MAXPATHLEN - 2; |
|||
|
|||
if (\strlen($filename) > $maxPathLength) { |
|||
throw new IOException(sprintf('Could not check if file is readable because path length exceeds %d characters.', $maxPathLength), 0, null, $filename); |
|||
} |
|||
|
|||
return is_readable($filename); |
|||
} |
|||
|
|||
/** |
|||
* Creates a symbolic link or copy a directory. |
|||
* |
|||
* @throws IOException When symlink fails |
|||
*/ |
|||
public static function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false) { |
|||
self::assertFunctionExists('symlink'); |
|||
|
|||
if ('\\' === \DIRECTORY_SEPARATOR) { |
|||
$originDir = strtr($originDir, '/', '\\'); |
|||
$targetDir = strtr($targetDir, '/', '\\'); |
|||
|
|||
if ($copyOnWindows) { |
|||
self::mirror($originDir, $targetDir); |
|||
|
|||
return; |
|||
} |
|||
} |
|||
|
|||
self::mkdir(\dirname($targetDir)); |
|||
|
|||
if (is_link($targetDir)) { |
|||
if (readlink($targetDir) === $originDir) { |
|||
return; |
|||
} |
|||
self::remove($targetDir); |
|||
} |
|||
|
|||
if (!self::box('symlink', $originDir, $targetDir)) { |
|||
self::linkException($originDir, $targetDir, 'symbolic'); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Creates a hard link, or several hard links to a file. |
|||
* |
|||
* @param string|string[] $targetFiles The target file(s) |
|||
* |
|||
* @throws FileNotFoundException When original file is missing or not a file |
|||
* @throws IOException When link fails, including if link already exists |
|||
*/ |
|||
public static function hardlink(string $originFile, string|iterable $targetFiles) { |
|||
self::assertFunctionExists('link'); |
|||
|
|||
if (!self::exists($originFile)) { |
|||
throw new FileNotFoundException(null, 0, null, $originFile); |
|||
} |
|||
|
|||
if (!is_file($originFile)) { |
|||
throw new FileNotFoundException(sprintf('Origin file "%s" is not a file.', $originFile)); |
|||
} |
|||
|
|||
foreach (self::toIterable($targetFiles) as $targetFile) { |
|||
if (is_file($targetFile)) { |
|||
if (fileinode($originFile) === fileinode($targetFile)) { |
|||
continue; |
|||
} |
|||
self::remove($targetFile); |
|||
} |
|||
|
|||
if (!self::box('link', $originFile, $targetFile)) { |
|||
self::linkException($originFile, $targetFile, 'hard'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* @param string $linkType Name of the link type, typically 'symbolic' or 'hard' |
|||
*/ |
|||
private static function linkException(string $origin, string $target, string $linkType) { |
|||
if (self::$lastError) { |
|||
if ('\\' === \DIRECTORY_SEPARATOR && str_contains(self::$lastError, 'error code(1314)')) { |
|||
throw new IOException(sprintf('Unable to create "%s" link due to error code 1314: \'A required privilege is not held by the client\'. Do you have the required Administrator-rights?', $linkType), 0, null, $target); |
|||
} |
|||
} |
|||
throw new IOException(sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target); |
|||
} |
|||
|
|||
/** |
|||
* Resolves links in paths. |
|||
* |
|||
* With $canonicalize = false (default) |
|||
* - if $path does not exist or is not a link, returns null |
|||
* - if $path is a link, returns the next direct target of the link without considering the existence of the target |
|||
* |
|||
* With $canonicalize = true |
|||
* - if $path does not exist, returns null |
|||
* - if $path exists, returns its absolute fully resolved final version |
|||
*/ |
|||
public static function readlink(string $path, bool $canonicalize = false): ?string { |
|||
if (!$canonicalize && !is_link($path)) { |
|||
return null; |
|||
} |
|||
|
|||
if ($canonicalize) { |
|||
if (!self::exists($path)) { |
|||
return null; |
|||
} |
|||
|
|||
return realpath($path); |
|||
} |
|||
|
|||
return readlink($path); |
|||
} |
|||
|
|||
/** |
|||
* Given an existing path, convert it to a path relative to a given starting path. |
|||
*/ |
|||
public static function makePathRelative(string $endPath, string $startPath): string { |
|||
if (!self::isAbsolutePath($startPath)) { |
|||
throw new \InvalidArgumentException(sprintf('The start path "%s" is not absolute.', $startPath)); |
|||
} |
|||
|
|||
if (!self::isAbsolutePath($endPath)) { |
|||
throw new \InvalidArgumentException(sprintf('The end path "%s" is not absolute.', $endPath)); |
|||
} |
|||
|
|||
// Normalize separators on Windows |
|||
if ('\\' === \DIRECTORY_SEPARATOR) { |
|||
$endPath = str_replace('\\', '/', $endPath); |
|||
$startPath = str_replace('\\', '/', $startPath); |
|||
} |
|||
|
|||
$splitDriveLetter = function ($path) { |
|||
return (\strlen($path) > 2 && ':' === $path[1] && '/' === $path[2] && ctype_alpha($path[0])) |
|||
? [substr($path, 2), strtoupper($path[0])] |
|||
: [$path, null]; |
|||
}; |
|||
|
|||
$splitPath = function ($path) { |
|||
$result = []; |
|||
|
|||
foreach (explode('/', trim($path, '/')) as $segment) { |
|||
if ('..' === $segment) { |
|||
array_pop($result); |
|||
} elseif ('.' !== $segment && '' !== $segment) { |
|||
$result[] = $segment; |
|||
} |
|||
} |
|||
|
|||
return $result; |
|||
}; |
|||
|
|||
[$endPath, $endDriveLetter] = $splitDriveLetter($endPath); |
|||
[$startPath, $startDriveLetter] = $splitDriveLetter($startPath); |
|||
|
|||
$startPathArr = $splitPath($startPath); |
|||
$endPathArr = $splitPath($endPath); |
|||
|
|||
if ($endDriveLetter && $startDriveLetter && $endDriveLetter != $startDriveLetter) { |
|||
// End path is on another drive, so no relative path exists |
|||
return $endDriveLetter.':/'.($endPathArr ? implode('/', $endPathArr).'/' : ''); |
|||
} |
|||
|
|||
// Find for which directory the common path stops |
|||
$index = 0; |
|||
while (isset($startPathArr[$index]) && isset($endPathArr[$index]) && $startPathArr[$index] === $endPathArr[$index]) { |
|||
++$index; |
|||
} |
|||
|
|||
// Determine how deep the start path is relative to the common path (ie, "web/bundles" = 2 levels) |
|||
if (1 === \count($startPathArr) && '' === $startPathArr[0]) { |
|||
$depth = 0; |
|||
} else { |
|||
$depth = \count($startPathArr) - $index; |
|||
} |
|||
|
|||
// Repeated "../" for each level need to reach the common path |
|||
$traverser = str_repeat('../', $depth); |
|||
|
|||
$endPathRemainder = implode('/', \array_slice($endPathArr, $index)); |
|||
|
|||
// Construct $endPath from traversing to the common path, then to the remaining $endPath |
|||
$relativePath = $traverser.('' !== $endPathRemainder ? $endPathRemainder.'/' : ''); |
|||
|
|||
return '' === $relativePath ? './' : $relativePath; |
|||
} |
|||
|
|||
/** |
|||
* Mirrors a directory to another. |
|||
* |
|||
* Copies files and directories from the origin directory into the target directory. By default: |
|||
* |
|||
* - existing files in the target directory will be overwritten, except if they are newer (see the `override` option) |
|||
* - files in the target directory that do not exist in the source directory will not be deleted (see the `delete` option) |
|||
* |
|||
* @param \Traversable|null $iterator Iterator that filters which files and directories to copy, if null a recursive iterator is created |
|||
* @param array $options An array of boolean options |
|||
* Valid options are: |
|||
* - $options['override'] If true, target files newer than origin files are overwritten (see copy(), defaults to false) |
|||
* - $options['copy_on_windows'] Whether to copy files instead of links on Windows (see symlink(), defaults to false) |
|||
* - $options['delete'] Whether to delete files that are not in the source directory (defaults to false) |
|||
* |
|||
* @throws IOException When file type is unknown |
|||
*/ |
|||
public static function mirror(string $originDir, string $targetDir, \Traversable $iterator = null, array $options = []) { |
|||
$targetDir = rtrim($targetDir, '/\\'); |
|||
$originDir = rtrim($originDir, '/\\'); |
|||
$originDirLen = \strlen($originDir); |
|||
|
|||
if (!self::exists($originDir)) { |
|||
throw new IOException(sprintf('The origin directory specified "%s" was not found.', $originDir), 0, null, $originDir); |
|||
} |
|||
|
|||
// Iterate in destination folder to remove obsolete entries |
|||
if (self::exists($targetDir) && isset($options['delete']) && $options['delete']) { |
|||
$deleteIterator = $iterator; |
|||
if (null === $deleteIterator) { |
|||
$flags = \FilesystemIterator::SKIP_DOTS; |
|||
$deleteIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($targetDir, $flags), \RecursiveIteratorIterator::CHILD_FIRST); |
|||
} |
|||
$targetDirLen = \strlen($targetDir); |
|||
foreach ($deleteIterator as $file) { |
|||
$origin = $originDir.substr($file->getPathname(), $targetDirLen); |
|||
if (!self::exists($origin)) { |
|||
self::remove($file); |
|||
} |
|||
} |
|||
} |
|||
|
|||
$copyOnWindows = $options['copy_on_windows'] ?? false; |
|||
|
|||
if (null === $iterator) { |
|||
$flags = $copyOnWindows ? \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS : \FilesystemIterator::SKIP_DOTS; |
|||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($originDir, $flags), \RecursiveIteratorIterator::SELF_FIRST); |
|||
} |
|||
|
|||
self::mkdir($targetDir); |
|||
$filesCreatedWhileMirroring = []; |
|||
|
|||
foreach ($iterator as $file) { |
|||
if ($file->getPathname() === $targetDir || $file->getRealPath() === $targetDir || isset($filesCreatedWhileMirroring[$file->getRealPath()])) { |
|||
continue; |
|||
} |
|||
|
|||
$target = $targetDir.substr($file->getPathname(), $originDirLen); |
|||
$filesCreatedWhileMirroring[$target] = true; |
|||
|
|||
if (!$copyOnWindows && is_link($file)) { |
|||
self::symlink($file->getLinkTarget(), $target); |
|||
} elseif (is_dir($file)) { |
|||
self::mkdir($target); |
|||
} elseif (is_file($file)) { |
|||
self::copy($file, $target, $options['override'] ?? false); |
|||
} else { |
|||
throw new IOException(sprintf('Unable to guess "%s" file type.', $file), 0, null, $file); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Returns whether the file path is an absolute path. |
|||
*/ |
|||
public static function isAbsolutePath(string $file): bool { |
|||
return '' !== $file && (strspn($file, '/\\', 0, 1) |
|||
|| (\strlen($file) > 3 && ctype_alpha($file[0]) |
|||
&& ':' === $file[1] |
|||
&& strspn($file, '/\\', 2, 1) |
|||
) |
|||
|| null !== parse_url($file, \PHP_URL_SCHEME) |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* Creates a temporary file with support for custom stream wrappers. |
|||
* |
|||
* @param string $prefix The prefix of the generated temporary filename |
|||
* Note: Windows uses only the first three characters of prefix |
|||
* @param string $suffix The suffix of the generated temporary filename |
|||
* |
|||
* @return string The new temporary filename (with path), or throw an exception on failure |
|||
*/ |
|||
public static function tempnam(string $dir, string $prefix, string $suffix = ''): string { |
|||
[$scheme, $hierarchy] = self::getSchemeAndHierarchy($dir); |
|||
|
|||
// If no scheme or scheme is "file" or "gs" (Google Cloud) create temp file in local filesystem |
|||
if ((null === $scheme || 'file' === $scheme || 'gs' === $scheme) && '' === $suffix) { |
|||
// If tempnam failed or no scheme return the filename otherwise prepend the scheme |
|||
if ($tmpFile = self::box('tempnam', $hierarchy, $prefix)) { |
|||
if (null !== $scheme && 'gs' !== $scheme) { |
|||
return $scheme.'://'.$tmpFile; |
|||
} |
|||
|
|||
return $tmpFile; |
|||
} |
|||
|
|||
throw new IOException('A temporary file could not be created: '.self::$lastError); |
|||
} |
|||
|
|||
// Loop until we create a valid temp file or have reached 10 attempts |
|||
for ($i = 0; $i < 10; ++$i) { |
|||
// Create a unique filename |
|||
$tmpFile = $dir.'/'.$prefix.uniqid((string)mt_rand(), true).$suffix; |
|||
|
|||
// Use fopen instead of file_exists as some streams do not support stat |
|||
// Use mode 'x+' to atomically check existence and create to avoid a TOCTOU vulnerability |
|||
if (!$handle = self::box('fopen', $tmpFile, 'x+')) { |
|||
continue; |
|||
} |
|||
|
|||
// Close the file if it was successfully opened |
|||
self::box('fclose', $handle); |
|||
|
|||
return $tmpFile; |
|||
} |
|||
|
|||
throw new IOException('A temporary file could not be created: '.self::$lastError); |
|||
} |
|||
|
|||
/** |
|||
* Atomically dumps content into a file. |
|||
* |
|||
* @param string|resource $content The data to write into the file |
|||
* |
|||
* @throws IOException if the file cannot be written to |
|||
*/ |
|||
public static function dumpFile(string $filename, $content) { |
|||
if (\is_array($content)) { |
|||
throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); |
|||
} |
|||
|
|||
$dir = \dirname($filename); |
|||
|
|||
if (!is_dir($dir)) { |
|||
self::mkdir($dir); |
|||
} |
|||
|
|||
// Will create a temp file with 0600 access rights |
|||
// when the filesystem supports chmod. |
|||
$tmpFile = self::tempnam($dir, basename($filename)); |
|||
|
|||
try { |
|||
if (false === self::box('file_put_contents', $tmpFile, $content)) { |
|||
throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); |
|||
} |
|||
|
|||
self::box('chmod', $tmpFile, file_exists($filename) ? fileperms($filename) : 0666 & ~umask()); |
|||
|
|||
self::rename($tmpFile, $filename, true); |
|||
} finally { |
|||
if (file_exists($tmpFile)) { |
|||
self::box('unlink', $tmpFile); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Appends content to an existing file. |
|||
* |
|||
* @param string|resource $content The content to append |
|||
* @param bool $lock Whether the file should be locked when writing to it |
|||
* |
|||
* @throws IOException If the file is not writable |
|||
*/ |
|||
public static function appendToFile(string $filename, $content/* , bool $lock = false */) { |
|||
if (\is_array($content)) { |
|||
throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); |
|||
} |
|||
|
|||
$dir = \dirname($filename); |
|||
|
|||
if (!is_dir($dir)) { |
|||
self::mkdir($dir); |
|||
} |
|||
|
|||
$lock = \func_num_args() > 2 && func_get_arg(2); |
|||
|
|||
if (false === self::box('file_put_contents', $filename, $content, \FILE_APPEND | ($lock ? \LOCK_EX : 0))) { |
|||
throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); |
|||
} |
|||
} |
|||
|
|||
private static function toIterable(string|iterable $files): iterable { |
|||
return is_iterable($files) ? $files : [$files]; |
|||
} |
|||
|
|||
/** |
|||
* Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> [file, tmp]). |
|||
*/ |
|||
private static function getSchemeAndHierarchy(string $filename): array { |
|||
$components = explode('://', $filename, 2); |
|||
|
|||
return 2 === \count($components) ? [$components[0], $components[1]] : [null, $components[0]]; |
|||
} |
|||
|
|||
private static function assertFunctionExists(string $func): void { |
|||
if (!\function_exists($func)) { |
|||
throw new IOException(sprintf('Unable to perform filesystem operation because the "%s()" function has been disabled.', $func)); |
|||
} |
|||
} |
|||
|
|||
private static function box(string $func, mixed ...$args): mixed { |
|||
self::assertFunctionExists($func); |
|||
|
|||
self::$lastError = null; |
|||
set_error_handler(__CLASS__.'::handleError'); |
|||
try { |
|||
return $func(...$args); |
|||
} finally { |
|||
restore_error_handler(); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* @internal |
|||
*/ |
|||
public static function handleError(int $type, string $msg) { |
|||
self::$lastError = $msg; |
|||
} |
|||
|
|||
|
|||
private function __construct() {} |
|||
} |
@ -0,0 +1,31 @@ |
|||
<?php |
|||
/** |
|||
* @license MIT |
|||
* Copyright 2023 Dustin Wilson, J. King, et al. |
|||
* Original copyright 2023 Fabien Potencier |
|||
* See LICENSE and AUTHORS files for details |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
namespace MensBeam\Filesystem; |
|||
|
|||
|
|||
/** |
|||
* Exception class thrown when a file couldn't be found. |
|||
* |
|||
* @author Fabien Potencier <fabien@symfony.com> |
|||
* @author Christian Gärtner <christiangaertner.film@googlemail.com> |
|||
*/ |
|||
class FileNotFoundException extends IOException { |
|||
public function __construct(string $message = null, int $code = 0, \Throwable $previous = null, string $path = null) { |
|||
if (null === $message) { |
|||
if (null === $path) { |
|||
$message = 'File could not be found.'; |
|||
} else { |
|||
$message = sprintf('File "%s" could not be found.', $path); |
|||
} |
|||
} |
|||
|
|||
parent::__construct($message, $code, $previous, $path); |
|||
} |
|||
} |
@ -0,0 +1,32 @@ |
|||
<?php |
|||
/** |
|||
* @license MIT |
|||
* Copyright 2023 Dustin Wilson, J. King, et al. |
|||
* Original copyright 2023 Fabien Potencier |
|||
* See LICENSE and AUTHORS files for details |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
namespace MensBeam\Filesystem; |
|||
|
|||
|
|||
/** |
|||
* Exception class thrown when a filesystem operation failure happens. |
|||
* |
|||
* @author Romain Neutron <imprec@gmail.com> |
|||
* @author Christian Gärtner <christiangaertner.film@googlemail.com> |
|||
* @author Fabien Potencier <fabien@symfony.com> |
|||
*/ |
|||
class IOException extends \RuntimeException { |
|||
private ?string $path; |
|||
|
|||
public function __construct(string $message, int $code = 0, \Throwable $previous = null, string $path = null) { |
|||
$this->path = $path; |
|||
|
|||
parent::__construct($message, $code, $previous); |
|||
} |
|||
|
|||
public function getPath(): ?string { |
|||
return $this->path; |
|||
} |
|||
} |
@ -0,0 +1,792 @@ |
|||
<?php |
|||
/** |
|||
* @license MIT |
|||
* Copyright 2023 Dustin Wilson, J. King, et al. |
|||
* Original copyright 2023 Fabien Potencier |
|||
* See LICENSE and AUTHORS files for details |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
namespace MensBeam; |
|||
|
|||
|
|||
/** |
|||
* Contains utility methods for handling path strings. |
|||
* |
|||
* The methods in this class are able to deal with both UNIX and Windows paths |
|||
* with both forward and backward slashes. All methods return normalized parts |
|||
* containing only forward slashes and no excess "." and ".." segments. |
|||
* |
|||
* @author Bernhard Schussek <bschussek@gmail.com> |
|||
* @author Thomas Schulz <mail@king2500.net> |
|||
* @author Théo Fidry <theo.fidry@gmail.com> |
|||
*/ |
|||
class Path { |
|||
/** |
|||
* The number of buffer entries that triggers a cleanup operation. |
|||
*/ |
|||
protected const CLEANUP_THRESHOLD = 1250; |
|||
|
|||
/** |
|||
* The buffer size after the cleanup operation. |
|||
*/ |
|||
protected const CLEANUP_SIZE = 1000; |
|||
|
|||
/** |
|||
* Buffers input/output of {@link canonicalize()}. |
|||
* |
|||
* @var array<string, string> |
|||
*/ |
|||
protected static $buffer = []; |
|||
|
|||
/** |
|||
* @var int |
|||
*/ |
|||
protected static $bufferSize = 0; |
|||
|
|||
/** |
|||
* Canonicalizes the given path. |
|||
* |
|||
* During normalization, all slashes are replaced by forward slashes ("/"). |
|||
* Furthermore, all "." and ".." segments are removed as far as possible. |
|||
* ".." segments at the beginning of relative paths are not removed. |
|||
* |
|||
* ```php |
|||
* echo Path::canonicalize("\symfony\puli\..\css\style.css"); |
|||
* // => /symfony/css/style.css |
|||
* |
|||
* echo Path::canonicalize("../css/./style.css"); |
|||
* // => ../css/style.css |
|||
* ``` |
|||
* |
|||
* This method is able to deal with both UNIX and Windows paths. |
|||
*/ |
|||
public static function canonicalize(string $path): string { |
|||
if ('' === $path) { |
|||
return ''; |
|||
} |
|||
|
|||
// This method is called by many other methods in this class. Buffer |
|||
// the canonicalized paths to make up for the severe performance |
|||
// decrease. |
|||
if (isset(self::$buffer[$path])) { |
|||
return self::$buffer[$path]; |
|||
} |
|||
|
|||
// Replace "~" with user's home directory. |
|||
if ('~' === $path[0]) { |
|||
$path = self::getHomeDirectory().substr($path, 1); |
|||
} |
|||
|
|||
$path = self::normalize($path); |
|||
|
|||
[$root, $pathWithoutRoot] = self::split($path); |
|||
|
|||
$canonicalParts = self::findCanonicalParts($root, $pathWithoutRoot); |
|||
|
|||
// Add the root directory again |
|||
self::$buffer[$path] = $canonicalPath = $root.implode('/', $canonicalParts); |
|||
++self::$bufferSize; |
|||
|
|||
// Clean up regularly to prevent memory leaks |
|||
if (self::$bufferSize > self::CLEANUP_THRESHOLD) { |
|||
self::$buffer = \array_slice(self::$buffer, -self::CLEANUP_SIZE, null, true); |
|||
self::$bufferSize = self::CLEANUP_SIZE; |
|||
} |
|||
|
|||
return $canonicalPath; |
|||
} |
|||
|
|||
/** |
|||
* Normalizes the given path. |
|||
* |
|||
* During normalization, all slashes are replaced by forward slashes ("/"). |
|||
* Contrary to {@link canonicalize()}, this method does not remove invalid |
|||
* or dot path segments. Consequently, it is much more efficient and should |
|||
* be used whenever the given path is known to be a valid, absolute system |
|||
* path. |
|||
* |
|||
* This method is able to deal with both UNIX and Windows paths. |
|||
*/ |
|||
public static function normalize(string $path): string { |
|||
return str_replace('\\', '/', $path); |
|||
} |
|||
|
|||
/** |
|||
* Returns the directory part of the path. |
|||
* |
|||
* This method is similar to PHP's dirname(), but handles various cases |
|||
* where dirname() returns a weird result: |
|||
* |
|||
* - dirname() does not accept backslashes on UNIX |
|||
* - dirname("C:/symfony") returns "C:", not "C:/" |
|||
* - dirname("C:/") returns ".", not "C:/" |
|||
* - dirname("C:") returns ".", not "C:/" |
|||
* - dirname("symfony") returns ".", not "" |
|||
* - dirname() does not canonicalize the result |
|||
* |
|||
* This method fixes these shortcomings and behaves like dirname() |
|||
* otherwise. |
|||
* |
|||
* The result is a canonical path. |
|||
* |
|||
* @return string The canonical directory part. Returns the root directory |
|||
* if the root directory is passed. Returns an empty string |
|||
* if a relative path is passed that contains no slashes. |
|||
* Returns an empty string if an empty string is passed. |
|||
*/ |
|||
public static function getDirectory(string $path): string { |
|||
if ('' === $path) { |
|||
return ''; |
|||
} |
|||
|
|||
$path = self::canonicalize($path); |
|||
|
|||
// Maintain scheme |
|||
if (false !== $schemeSeparatorPosition = strpos($path, '://')) { |
|||
$scheme = substr($path, 0, $schemeSeparatorPosition + 3); |
|||
$path = substr($path, $schemeSeparatorPosition + 3); |
|||
} else { |
|||
$scheme = ''; |
|||
} |
|||
|
|||
if (false === $dirSeparatorPosition = strrpos($path, '/')) { |
|||
return ''; |
|||
} |
|||
|
|||
// Directory equals root directory "/" |
|||
if (0 === $dirSeparatorPosition) { |
|||
return $scheme.'/'; |
|||
} |
|||
|
|||
// Directory equals Windows root "C:/" |
|||
if (2 === $dirSeparatorPosition && ctype_alpha($path[0]) && ':' === $path[1]) { |
|||
return $scheme.substr($path, 0, 3); |
|||
} |
|||
|
|||
return $scheme.substr($path, 0, $dirSeparatorPosition); |
|||
} |
|||
|
|||
/** |
|||
* Returns canonical path of the user's home directory. |
|||
* |
|||
* Supported operating systems: |
|||
* |
|||
* - UNIX |
|||
* - Windows8 and upper |
|||
* |
|||
* If your operating system or environment isn't supported, an exception is thrown. |
|||
* |
|||
* The result is a canonical path. |
|||
* |
|||
* @throws \RuntimeException If your operating system or environment isn't supported |
|||
*/ |
|||
public static function getHomeDirectory(): string { |
|||
// For UNIX support |
|||
if (getenv('HOME')) { |
|||
return self::canonicalize(getenv('HOME')); |
|||
} |
|||
|
|||
// For >= Windows8 support |
|||
if (getenv('HOMEDRIVE') && getenv('HOMEPATH')) { |
|||
return self::canonicalize(getenv('HOMEDRIVE').getenv('HOMEPATH')); |
|||
} |
|||
|
|||
throw new \RuntimeException("Cannot find the home directory path: Your environment or operating system isn't supported."); |
|||
} |
|||
|
|||
/** |
|||
* Returns the root directory of a path. |
|||
* |
|||
* The result is a canonical path. |
|||
* |
|||
* @return string The canonical root directory. Returns an empty string if |
|||
* the given path is relative or empty. |
|||
*/ |
|||
public static function getRoot(string $path): string { |
|||
if ('' === $path) { |
|||
return ''; |
|||
} |
|||
|
|||
// Maintain scheme |
|||
if (false !== $schemeSeparatorPosition = strpos($path, '://')) { |
|||
$scheme = substr($path, 0, $schemeSeparatorPosition + 3); |
|||
$path = substr($path, $schemeSeparatorPosition + 3); |
|||
} else { |
|||
$scheme = ''; |
|||
} |
|||
|
|||
$firstCharacter = $path[0]; |
|||
|
|||
// UNIX root "/" or "\" (Windows style) |
|||
if ('/' === $firstCharacter || '\\' === $firstCharacter) { |
|||
return $scheme.'/'; |
|||
} |
|||
|
|||
$length = \strlen($path); |
|||
|
|||
// Windows root |
|||
if ($length > 1 && ':' === $path[1] && ctype_alpha($firstCharacter)) { |
|||
// Special case: "C:" |
|||
if (2 === $length) { |
|||
return $scheme.$path.'/'; |
|||
} |
|||
|
|||
// Normal case: "C:/ or "C:\" |
|||
if ('/' === $path[2] || '\\' === $path[2]) { |
|||
return $scheme.$firstCharacter.$path[1].'/'; |
|||
} |
|||
} |
|||
|
|||
return ''; |
|||
} |
|||
|
|||
/** |
|||
* Returns the file name without the extension from a file path. |
|||
* |
|||
* @param string|null $extension if specified, only that extension is cut |
|||
* off (may contain leading dot) |
|||
*/ |
|||
public static function getFilenameWithoutExtension(string $path, string $extension = null): string { |
|||
if ('' === $path) { |
|||
return ''; |
|||
} |
|||
|
|||
if (null !== $extension) { |
|||
// remove extension and trailing dot |
|||
return rtrim(basename($path, $extension), '.'); |
|||
} |
|||
|
|||
return pathinfo($path, \PATHINFO_FILENAME); |
|||
} |
|||
|
|||
/** |
|||
* Returns the extension from a file path (without leading dot). |
|||
* |
|||
* @param bool $forceLowerCase forces the extension to be lower-case |
|||
*/ |
|||
public static function getExtension(string $path, bool $forceLowerCase = false): string { |
|||
if ('' === $path) { |
|||
return ''; |
|||
} |
|||
|
|||
$extension = pathinfo($path, \PATHINFO_EXTENSION); |
|||
|
|||
if ($forceLowerCase) { |
|||
$extension = self::toLower($extension); |
|||
} |
|||
|
|||
return $extension; |
|||
} |
|||
|
|||
/** |
|||
* Returns whether the path has an (or the specified) extension. |
|||
* |
|||
* @param string $path the path string |
|||
* @param string|string[]|null $extensions if null or not provided, checks if |
|||
* an extension exists, otherwise |
|||
* checks for the specified extension |
|||
* or array of extensions (with or |
|||
* without leading dot) |
|||
* @param bool $ignoreCase whether to ignore case-sensitivity |
|||
*/ |
|||
public static function hasExtension(string $path, $extensions = null, bool $ignoreCase = false): bool { |
|||
if ('' === $path) { |
|||
return false; |
|||
} |
|||
|
|||
$actualExtension = self::getExtension($path, $ignoreCase); |
|||
|
|||
// Only check if path has any extension |
|||
if ([] === $extensions || null === $extensions) { |
|||
return '' !== $actualExtension; |
|||
} |
|||
|
|||
if (\is_string($extensions)) { |
|||
$extensions = [$extensions]; |
|||
} |
|||
|
|||
foreach ($extensions as $key => $extension) { |
|||
if ($ignoreCase) { |
|||
$extension = self::toLower($extension); |
|||
} |
|||
|
|||
// remove leading '.' in extensions array |
|||
$extensions[$key] = ltrim($extension, '.'); |
|||
} |
|||
|
|||
return \in_array($actualExtension, $extensions, true); |
|||
} |
|||
|
|||
/** |
|||
* Changes the extension of a path string. |
|||
* |
|||
* @param string $path The path string with filename.ext to change. |
|||
* @param string $extension new extension (with or without leading dot) |
|||
* |
|||
* @return string the path string with new file extension |
|||
*/ |
|||
public static function changeExtension(string $path, string $extension): string { |
|||
if ('' === $path) { |
|||
return ''; |
|||
} |
|||
|
|||
$actualExtension = self::getExtension($path); |
|||
$extension = ltrim($extension, '.'); |
|||
|
|||
// No extension for paths |
|||
if ('/' === substr($path, -1)) { |
|||
return $path; |
|||
} |
|||
|
|||
// No actual extension in path |
|||
if (empty($actualExtension)) { |
|||
return $path.('.' === substr($path, -1) ? '' : '.').$extension; |
|||
} |
|||
|
|||
return substr($path, 0, -\strlen($actualExtension)).$extension; |
|||
} |
|||
|
|||
public static function isAbsolute(string $path): bool { |
|||
if ('' === $path) { |
|||
return false; |
|||
} |
|||
|
|||
// Strip scheme |
|||
if (false !== $schemeSeparatorPosition = strpos($path, '://')) { |
|||
$path = substr($path, $schemeSeparatorPosition + 3); |
|||
} |
|||
|
|||
$firstCharacter = $path[0]; |
|||
|
|||
// UNIX root "/" or "\" (Windows style) |
|||
if ('/' === $firstCharacter || '\\' === $firstCharacter) { |
|||
return true; |
|||
} |
|||
|
|||
// Windows root |
|||
if (\strlen($path) > 1 && ctype_alpha($firstCharacter) && ':' === $path[1]) { |
|||
// Special case: "C:" |
|||
if (2 === \strlen($path)) { |
|||
return true; |
|||
} |
|||
|
|||
// Normal case: "C:/ or "C:\" |
|||
if ('/' === $path[2] || '\\' === $path[2]) { |
|||
return true; |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public static function isRelative(string $path): bool { |
|||
return !self::isAbsolute($path); |
|||
} |
|||
|
|||
/** |
|||
* Turns a relative path into an absolute path in canonical form. |
|||
* |
|||
* Usually, the relative path is appended to the given base path. Dot |
|||
* segments ("." and "..") are removed/collapsed and all slashes turned |
|||
* into forward slashes. |
|||
* |
|||
* ```php |
|||
* echo Path::makeAbsolute("../style.css", "/symfony/puli/css"); |
|||
* // => /symfony/puli/style.css |
|||
* ``` |
|||
* |
|||
* If an absolute path is passed, that path is returned unless its root |
|||
* directory is different than the one of the base path. In that case, an |
|||
* exception is thrown. |
|||
* |
|||
* ```php |
|||
* Path::makeAbsolute("/style.css", "/symfony/puli/css"); |
|||
* // => /style.css |
|||
* |
|||
* Path::makeAbsolute("C:/style.css", "C:/symfony/puli/css"); |
|||
* // => C:/style.css |
|||
* |
|||
* Path::makeAbsolute("C:/style.css", "/symfony/puli/css"); |
|||
* // InvalidArgumentException |
|||
* ``` |
|||
* |
|||
* If the base path is not an absolute path, an exception is thrown. |
|||
* |
|||
* The result is a canonical path. |
|||
* |
|||
* @param string $basePath an absolute base path |
|||
* |
|||
* @throws \InvalidArgumentException if the base path is not absolute or if |
|||
* the given path is an absolute path with |
|||
* a different root than the base path |
|||
*/ |
|||
public static function makeAbsolute(string $path, string $basePath): string { |
|||
if ('' === $basePath) { |
|||
throw new \InvalidArgumentException(sprintf('The base path must be a non-empty string. Got: "%s".', $basePath)); |
|||
} |
|||
|
|||
if (!self::isAbsolute($basePath)) { |
|||
throw new \InvalidArgumentException(sprintf('The base path "%s" is not an absolute path.', $basePath)); |
|||
} |
|||
|
|||
if (self::isAbsolute($path)) { |
|||
return self::canonicalize($path); |
|||
} |
|||
|
|||
if (false !== $schemeSeparatorPosition = strpos($basePath, '://')) { |
|||
$scheme = substr($basePath, 0, $schemeSeparatorPosition + 3); |
|||
$basePath = substr($basePath, $schemeSeparatorPosition + 3); |
|||
} else { |
|||
$scheme = ''; |
|||
} |
|||
|
|||
return $scheme.self::canonicalize(rtrim($basePath, '/\\').'/'.$path); |
|||
} |
|||
|
|||
/** |
|||
* Turns a path into a relative path. |
|||
* |
|||
* The relative path is created relative to the given base path: |
|||
* |
|||
* ```php |
|||
* echo Path::makeRelative("/symfony/style.css", "/symfony/puli"); |
|||
* // => ../style.css |
|||
* ``` |
|||
* |
|||
* If a relative path is passed and the base path is absolute, the relative |
|||
* path is returned unchanged: |
|||
* |
|||
* ```php |
|||
* Path::makeRelative("style.css", "/symfony/puli/css"); |
|||
* // => style.css |
|||
* ``` |
|||
* |
|||
* If both paths are relative, the relative path is created with the |
|||
* assumption that both paths are relative to the same directory: |
|||
* |
|||
* ```php |
|||
* Path::makeRelative("style.css", "symfony/puli/css"); |
|||
* // => ../../../style.css |
|||
* ``` |
|||
* |
|||
* If both paths are absolute, their root directory must be the same, |
|||
* otherwise an exception is thrown: |
|||
* |
|||
* ```php |
|||
* Path::makeRelative("C:/symfony/style.css", "/symfony/puli"); |
|||
* // InvalidArgumentException |
|||
* ``` |
|||
* |
|||
* If the passed path is absolute, but the base path is not, an exception |
|||
* is thrown as well: |
|||
* |
|||
* ```php |
|||
* Path::makeRelative("/symfony/style.css", "symfony/puli"); |
|||
* // InvalidArgumentException |
|||
* ``` |
|||
* |
|||
* If the base path is not an absolute path, an exception is thrown. |
|||
* |
|||
* The result is a canonical path. |
|||
* |
|||
* @throws InvalidArgumentException if the base path is not absolute or if |
|||
* the given path has a different root |
|||
* than the base path |
|||
*/ |
|||
public static function makeRelative(string $path, string $basePath): string { |
|||
$path = self::canonicalize($path); |
|||
$basePath = self::canonicalize($basePath); |
|||
|
|||
[$root, $relativePath] = self::split($path); |
|||
[$baseRoot, $relativeBasePath] = self::split($basePath); |
|||
|
|||
// If the base path is given as absolute path and the path is already |
|||
// relative, consider it to be relative to the given absolute path |
|||
// already |
|||
if ('' === $root && '' !== $baseRoot) { |
|||
// If base path is already in its root |
|||
if ('' === $relativeBasePath) { |
|||
$relativePath = ltrim($relativePath, './\\'); |
|||
} |
|||
|
|||
return $relativePath; |
|||
} |
|||
|
|||
// If the passed path is absolute, but the base path is not, we |
|||
// cannot generate a relative path |
|||
if ('' !== $root && '' === $baseRoot) { |
|||
throw new \InvalidArgumentException(sprintf('The absolute path "%s" cannot be made relative to the relative path "%s". You should provide an absolute base path instead.', $path, $basePath)); |
|||
} |
|||
|
|||
// Fail if the roots of the two paths are different |
|||
if ($baseRoot && $root !== $baseRoot) { |
|||
throw new \InvalidArgumentException(sprintf('The path "%s" cannot be made relative to "%s", because they have different roots ("%s" and "%s").', $path, $basePath, $root, $baseRoot)); |
|||
} |
|||
|
|||
if ('' === $relativeBasePath) { |
|||
return $relativePath; |
|||
} |
|||
|
|||
// Build a "../../" prefix with as many "../" parts as necessary |
|||
$parts = explode('/', $relativePath); |
|||
$baseParts = explode('/', $relativeBasePath); |
|||
$dotDotPrefix = ''; |
|||
|
|||
// Once we found a non-matching part in the prefix, we need to add |
|||
// "../" parts for all remaining parts |
|||
$match = true; |
|||
|
|||
foreach ($baseParts as $index => $basePart) { |
|||
if ($match && isset($parts[$index]) && $basePart === $parts[$index]) { |
|||
unset($parts[$index]); |
|||
|
|||
continue; |
|||
} |
|||
|
|||
$match = false; |
|||
$dotDotPrefix .= '../'; |
|||
} |
|||
|
|||
return rtrim($dotDotPrefix.implode('/', $parts), '/'); |
|||
} |
|||
|
|||
/** |
|||
* Returns whether the given path is on the local filesystem. |
|||
*/ |
|||
public static function isLocal(string $path): bool { |
|||
return '' !== $path && !str_contains($path, '://'); |
|||
} |
|||
|
|||
/** |
|||
* Returns the longest common base path in canonical form of a set of paths or |
|||
* `null` if the paths are on different Windows partitions. |
|||
* |
|||
* Dot segments ("." and "..") are removed/collapsed and all slashes turned |
|||
* into forward slashes. |
|||
* |
|||
* ```php |
|||
* $basePath = Path::getLongestCommonBasePath( |
|||
* '/symfony/css/style.css', |
|||
* '/symfony/css/..' |
|||
* ); |
|||
* // => /symfony |
|||
* ``` |
|||
* |
|||
* The root is returned if no common base path can be found: |
|||
* |
|||
* ```php |
|||
* $basePath = Path::getLongestCommonBasePath( |
|||
* '/symfony/css/style.css', |
|||
* '/puli/css/..' |
|||
* ); |
|||
* // => / |
|||
* ``` |
|||
* |
|||
* If the paths are located on different Windows partitions, `null` is |
|||
* returned. |
|||
* |
|||
* ```php |
|||
* $basePath = Path::getLongestCommonBasePath( |
|||
* 'C:/symfony/css/style.css', |
|||
* 'D:/symfony/css/..' |
|||
* ); |
|||
* // => null |
|||
* ``` |
|||
*/ |
|||
public static function getLongestCommonBasePath(string ...$paths): ?string { |
|||
[$bpRoot, $basePath] = self::split(self::canonicalize(reset($paths))); |
|||
|
|||
for (next($paths); null !== key($paths) && '' !== $basePath; next($paths)) { |
|||
[$root, $path] = self::split(self::canonicalize(current($paths))); |
|||
|
|||
// If we deal with different roots (e.g. C:/ vs. D:/), it's time |
|||
// to quit |
|||
if ($root !== $bpRoot) { |
|||
return null; |
|||
} |
|||
|
|||
// Make the base path shorter until it fits into path |
|||
while (true) { |
|||
if ('.' === $basePath) { |
|||
// No more base paths |
|||
$basePath = ''; |
|||
|
|||
// next path |
|||
continue 2; |
|||
} |
|||
|
|||
// Prevent false positives for common prefixes |
|||
// see isBasePath() |
|||
if (str_starts_with($path.'/', $basePath.'/')) { |
|||
// next path |
|||
continue 2; |
|||
} |
|||
|
|||
$basePath = \dirname($basePath); |
|||
} |
|||
} |
|||
|
|||
return $bpRoot.$basePath; |
|||
} |
|||
|
|||
/** |
|||
* Joins two or more path strings into a canonical path. |
|||
*/ |
|||
public static function join(string ...$paths): string { |
|||
$finalPath = null; |
|||
$wasScheme = false; |
|||
|
|||
foreach ($paths as $path) { |
|||
if ('' === $path) { |
|||
continue; |
|||
} |
|||
|
|||
if (null === $finalPath) { |
|||
// For first part we keep slashes, like '/top', 'C:\' or 'phar://' |
|||
$finalPath = $path; |
|||
$wasScheme = str_contains($path, '://'); |
|||
continue; |
|||
} |
|||
|
|||
// Only add slash if previous part didn't end with '/' or '\' |
|||
if (!\in_array(substr($finalPath, -1), ['/', '\\'])) { |
|||
$finalPath .= '/'; |
|||
} |
|||
|
|||
// If first part included a scheme like 'phar://' we allow \current part to start with '/', otherwise trim |
|||
$finalPath .= $wasScheme ? $path : ltrim($path, '/'); |
|||
$wasScheme = false; |
|||
} |
|||
|
|||
if (null === $finalPath) { |
|||
return ''; |
|||
} |
|||
|
|||
return self::canonicalize($finalPath); |
|||
} |
|||
|
|||
/** |
|||
* Returns whether a path is a base path of another path. |
|||
* |
|||
* Dot segments ("." and "..") are removed/collapsed and all slashes turned |
|||
* into forward slashes. |
|||
* |
|||
* ```php |
|||
* Path::isBasePath('/symfony', '/symfony/css'); |
|||
* // => true |
|||
* |
|||
* Path::isBasePath('/symfony', '/symfony'); |
|||
* // => true |
|||
* |
|||
* Path::isBasePath('/symfony', '/symfony/..'); |
|||
* // => false |
|||
* |
|||
* Path::isBasePath('/symfony', '/puli'); |
|||
* // => false |
|||
* ``` |
|||
*/ |
|||
public static function isBasePath(string $basePath, string $ofPath): bool { |
|||
$basePath = self::canonicalize($basePath); |
|||
$ofPath = self::canonicalize($ofPath); |
|||
|
|||
// Append slashes to prevent false positives when two paths have |
|||
// a common prefix, for example /base/foo and /base/foobar. |
|||
// Don't append a slash for the root "/", because then that root |
|||
// won't be discovered as common prefix ("//" is not a prefix of |
|||
// "/foobar/"). |
|||
return str_starts_with($ofPath.'/', rtrim($basePath, '/').'/'); |
|||
} |
|||
|
|||
/** |
|||
* @return string[] |
|||
*/ |
|||
protected static function findCanonicalParts(string $root, string $pathWithoutRoot): array { |
|||
$parts = explode('/', $pathWithoutRoot); |
|||
|
|||
$canonicalParts = []; |
|||
|
|||
// Collapse "." and "..", if possible |
|||
foreach ($parts as $part) { |
|||
if ('.' === $part || '' === $part) { |
|||
continue; |
|||
} |
|||
|
|||
// Collapse ".." with the previous part, if one exists |
|||
// Don't collapse ".." if the previous part is also ".." |
|||
if ('..' === $part && \count($canonicalParts) > 0 && '..' !== $canonicalParts[\count($canonicalParts) - 1]) { |
|||
array_pop($canonicalParts); |
|||
|
|||
continue; |
|||
} |
|||
|
|||
// Only add ".." prefixes for relative paths |
|||
if ('..' !== $part || '' === $root) { |
|||
$canonicalParts[] = $part; |
|||
} |
|||
} |
|||
|
|||
return $canonicalParts; |
|||
} |
|||
|
|||
/** |
|||
* Splits a canonical path into its root directory and the remainder. |
|||
* |
|||
* If the path has no root directory, an empty root directory will be |
|||
* returned. |
|||
* |
|||
* If the root directory is a Windows style partition, the resulting root |
|||
* will always contain a trailing slash. |
|||
* |
|||
* list ($root, $path) = Path::split("C:/symfony") |
|||
* // => ["C:/", "symfony"] |
|||
* |
|||
* list ($root, $path) = Path::split("C:") |
|||
* // => ["C:/", ""] |
|||
* |
|||
* @return array{string, string} an array with the root directory and the remaining relative path |
|||
*/ |
|||
protected static function split(string $path): array { |
|||
if ('' === $path) { |
|||
return ['', '']; |
|||
} |
|||
|
|||
// Remember scheme as part of the root, if any |
|||
if (false !== $schemeSeparatorPosition = strpos($path, '://')) { |
|||
$root = substr($path, 0, $schemeSeparatorPosition + 3); |
|||
$path = substr($path, $schemeSeparatorPosition + 3); |
|||
} else { |
|||
$root = ''; |
|||
} |
|||
|
|||
$length = \strlen($path); |
|||
|
|||
// Remove and remember root directory |
|||
if (str_starts_with($path, '/')) { |
|||
$root .= '/'; |
|||
$path = $length > 1 ? substr($path, 1) : ''; |
|||
} elseif ($length > 1 && ctype_alpha($path[0]) && ':' === $path[1]) { |
|||
if (2 === $length) { |
|||
// Windows special case: "C:" |
|||
$root .= $path.'/'; |
|||
$path = ''; |
|||
} elseif ('/' === $path[2]) { |
|||
// Windows normal case: "C:/".. |
|||
$root .= substr($path, 0, 3); |
|||
$path = $length > 3 ? substr($path, 3) : ''; |
|||
} |
|||
} |
|||
|
|||
return [$root, $path]; |
|||
} |
|||
|
|||
protected static function toLower(string $string): string { |
|||
if (false !== $encoding = mb_detect_encoding($string, null, true)) { |
|||
return mb_strtolower($string, $encoding); |
|||
} |
|||
|
|||
return strtolower($string); |
|||
} |
|||
|
|||
private function __construct() {} |
|||
} |
Loading…
Reference in new issue