Browse Source

Initial commit

main
Dustin Wilson 1 year ago
commit
4629311199
  1. 75
      .gitignore
  2. 4
      AUTHORS
  3. 23
      LICENSE
  4. 35
      README.md
  5. 29
      composer.json
  6. 712
      lib/Filesystem.php
  7. 31
      lib/Filesystem/FileNotFoundException.php
  8. 32
      lib/Filesystem/IOException.php
  9. 792
      lib/Path.php

75
.gitignore

@ -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.*

4
AUTHORS

@ -0,0 +1,4 @@
Project leads
-------------
Dustin Wilson https://dustinwilson.com/
J. King https://jkingweb.ca/

23
LICENSE

@ -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.

35
README.md

@ -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": "*"
}
}
```

29
composer.json

@ -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"
}
}

712
lib/Filesystem.php

@ -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() {}
}

31
lib/Filesystem/FileNotFoundException.php

@ -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);
}
}

32
lib/Filesystem/IOException.php

@ -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;
}
}

792
lib/Path.php

@ -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…
Cancel
Save