diff --git a/CHANGELOG.md b/CHANGELOG.md index ed487e5acd5..0389bb5f40c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased - Fixed a bug where entry tab contents could remain visible when switching to other tabs, after changing the entry type. +- Added `craft\helpers\FileHelper::absolutePath()`. +- Added `craft\helpers\FileHelper::findClosestFile()`. +- Added `craft\helpers\FileHelper::isWithin()`. +- Added `craft\helpers\FileHelper::relativePath()`. ## 4.3.4 - 2022-11-29 diff --git a/src/helpers/FileHelper.php b/src/helpers/FileHelper.php index b8eb5a30692..6714602a9f9 100644 --- a/src/helpers/FileHelper.php +++ b/src/helpers/FileHelper.php @@ -67,6 +67,83 @@ public static function normalizePath($path, $ds = DIRECTORY_SEPARATOR): string return $path; } + /** + * Returns a relative path based on a source location or the current working directory. + * + * @param string $to The target path. + * @param string|null $from The source location. Defaults to the current working directory. + * @param string $ds the directory separator to be used in the normalized result. Defaults to `DIRECTORY_SEPARATOR`. + * @return string The relative path if possible, or an absolute path if the directory is not contained within `$from`. + * @since 4.3.5 + */ + public static function relativePath( + string $to, + ?string $from = null, + string $ds = DIRECTORY_SEPARATOR, + ): string { + $to = static::absolutePath($to, ds: $ds); + + if ($from === null) { + $from = FileHelper::normalizePath(getcwd(), $ds); + } else { + $from = static::absolutePath($from, ds: $ds); + } + + if ($from === $to) { + return '.'; + } + + if (!str_starts_with($to . $ds, $from . $ds)) { + return $to; + } + + return substr($to, strlen($from) + 1); + } + + /** + * Returns an absolute path based on a source location or the current working directory. + * + * @param string $to The target path. + * @param string|null $from The source location. Defaults to the current working directory. + * @param string $ds the directory separator to be used in the normalized result. Defaults to `DIRECTORY_SEPARATOR`. + * @return string + * @since 4.3.5 + */ + public static function absolutePath( + string $to, + ?string $from = null, + string $ds = DIRECTORY_SEPARATOR, + ): string { + $to = static::normalizePath($to, $ds); + + // Already absolute? + if (str_starts_with($to, $ds)) { + return $to; + } + + if ($from === null) { + $from = FileHelper::normalizePath(getcwd(), $ds); + } else { + $from = static::absolutePath($from, ds: $ds); + } + + return $from . $ds . $to; + } + + /** + * Returns whether the given path is within another path. + * + * @param string $path the path to check + * @param string $parentPath the parent path that `$path` should be within + * @return bool + */ + public static function isWithin(string $path, string $parentPath): bool + { + $path = static::absolutePath($path, ds: '/'); + $parentPath = static::absolutePath($parentPath, ds: '/'); + return $path !== $parentPath && str_starts_with("$path/", "$parentPath/"); + } + /** * @inheritdoc */ @@ -505,6 +582,41 @@ public static function clearDirectory(string $dir, array $options = []): void closedir($handle); } + /** + * Traverses up the filesystem looking for the closest file to the given directory. + * + * @param string $dir the directory at or above which the file will be looked for + * @param array $options options for file searching. See [[findFiles()]]. + * @return string|null the closest matching file + * @throws InvalidArgumentException if the directory is invalid + * @since 4.3.5 + */ + public static function findClosestFile(string $dir, array $options = []): ?string + { + $options['recursive'] = false; + $dir = static::absolutePath($dir, ds: '/'); + while (true) { + $exists = file_exists($dir); + try { + $files = static::findFiles($dir, $options); + } catch (InvalidArgumentException $e) { + if ($exists) { + return null; + } + throw $e; + } + + if (!empty($files)) { + return reset($files); + } + $parent = dirname($dir); + if ($parent === $dir) { + return null; + } + $dir = $parent; + } + } + /** * Returns the last modification time for the given path. * diff --git a/src/test/TestSetup.php b/src/test/TestSetup.php index fc684d2f1cb..e3cf74e8dfb 100644 --- a/src/test/TestSetup.php +++ b/src/test/TestSetup.php @@ -340,14 +340,16 @@ public static function configureCraft(): bool defined('CURLOPT_TIMEOUT_MS') || define('CURLOPT_TIMEOUT_MS', 155); defined('CURLOPT_CONNECTTIMEOUT_MS') || define('CURLOPT_CONNECTTIMEOUT_MS', 156); - $libPath = dirname(__DIR__, 2) . '/lib'; - $srcPath = dirname(__DIR__); + $repoRoot = dirname(__DIR__, 2); + $libPath = $repoRoot . '/lib'; + $srcPath = $repoRoot . '/src'; require $libPath . '/yii2/Yii.php'; require $srcPath . '/Craft.php'; // Set aliases Craft::setAlias('@vendor', $vendorPath); + Craft::setAlias('@craftcms', $repoRoot); Craft::setAlias('@lib', $libPath); Craft::setAlias('@craft', $srcPath); Craft::setAlias('@appicons', $srcPath . DIRECTORY_SEPARATOR . 'icons'); diff --git a/tests/unit/helpers/FileHelper/FileHelperTest.php b/tests/unit/helpers/FileHelper/FileHelperTest.php index f758e670dae..1d2e43e9ce0 100644 --- a/tests/unit/helpers/FileHelper/FileHelperTest.php +++ b/tests/unit/helpers/FileHelper/FileHelperTest.php @@ -94,6 +94,41 @@ public function testNormalizePath(string $expected, string $path, string $ds): v self::assertSame($expected, FileHelper::normalizePath($path, $ds)); } + /** + * @dataProvider absolutePathDataProvider + * @param string $expected + * @param string $to + * @param string|null $from + * @param string $ds + */ + public function testAbsolutePath(string $expected, string $to, ?string $from, string $ds): void + { + self::assertSame($expected, FileHelper::absolutePath($to, $from, $ds)); + } + + /** + * @dataProvider relativePathDataProvider + * @param string $expected + * @param string $to + * @param string|null $from + * @param string $ds + */ + public function testRelativePath(string $expected, string $to, ?string $from, string $ds): void + { + self::assertSame($expected, FileHelper::relativePath($to, $from, $ds)); + } + + /** + * @dataProvider isWithinDataProvider + * @param bool $expected + * @param string $path + * @param string $parentPath + */ + public function testIsWithin(bool $expected, string $path, string $parentPath): void + { + self::assertSame($expected, FileHelper::isWithin($path, $parentPath)); + } + /** * @dataProvider isDirectoryEmptyDataProvider * @param bool $expected @@ -235,6 +270,19 @@ public function testWriteToFileExceptions(): void }); } + /** + * @dataProvider findClosestFileDataProvider + */ + public function testFindClosestFile(string|null|false $expected, string $dir, array $options = []) + { + if ($expected === false) { + $this->expectException(InvalidArgumentException::class); + FileHelper::findClosestFile($dir, $options); + } else { + self::assertSame($expected, FileHelper::findClosestFile($dir, $options)); + } + } + /** * @return array */ @@ -255,6 +303,46 @@ public function normalizePathDataProvider(): array ]; } + /** + * @return array + */ + public function absolutePathDataProvider(): array + { + return [ + ['/foo/bar', 'bar', '/foo', '/'], + ['/foo/bar', '/foo/bar', null, '/'], + ['\\foo\\bar', 'bar', '/foo', '\\'], + [FileHelper::normalizePath(getcwd(), '/') . '/foo/bar', 'foo/bar', null, '/'], + [FileHelper::normalizePath(getcwd(), '/') . '/baz/foo/bar', 'foo/bar', 'baz', '/'], + ]; + } + + /** + * @return array + */ + public function relativePathDataProvider(): array + { + return [ + ['bar/baz', '/foo/bar/baz', '/foo', '/'], + ['bar\\baz', '/foo/bar/baz', '/foo', '\\'], + ['/foo/bar/baz', '/foo/bar/baz', '/test', '/'], + ]; + } + + /** + * @return array + */ + public function isWithinDataProvider(): array + { + return [ + [true, '/foo/bar', '/foo'], + [true, 'foo/bar', 'foo'], + [true, 'foo/bar', getcwd() . '/foo'], + [false, '/foo/bar', '\\foo\\bar'], + [false, '/baz', '/foo'], + ]; + } + /** * @return array */ @@ -348,6 +436,37 @@ public function writeToFileDataProvider(): array ]; } + /** + * @return array + */ + public function findClosestFileDataProvider(): array + { + return [ + [ + FileHelper::normalizePath(__DIR__ . '/sandbox/singlefile/foo.txt', '/'), + __DIR__ . '/sandbox/singlefile', + ], + [ + FileHelper::normalizePath(__DIR__ . '/sandbox/singlefile/foo.txt', '/'), + __DIR__ . '/sandbox/singlefile/nested', + [ + 'except' => ['ignore*'], + ], + ], + [ + null, + '/', + [ + 'only' => ['nonexistent.txt'], + ], + ], + [ + false, + __DIR__ . '/sandbox/singlefile/nonexistent', + ], + ]; + } + /** * @inheritdoc */ diff --git a/tests/unit/helpers/FileHelper/sandbox/singlefile/foo.txt b/tests/unit/helpers/FileHelper/sandbox/singlefile/foo.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/helpers/FileHelper/sandbox/singlefile/nested/ignore.txt b/tests/unit/helpers/FileHelper/sandbox/singlefile/nested/ignore.txt new file mode 100644 index 00000000000..e69de29bb2d