static-php-cli/src/SPC/store/FileSystem.php

646 lines
24 KiB
PHP
Raw Normal View History

2023-03-15 20:40:49 +08:00
<?php
declare(strict_types=1);
namespace SPC\store;
use SPC\exception\FileSystemException;
use SPC\exception\RuntimeException;
class FileSystem
{
2023-04-30 12:42:19 +08:00
private static array $_extract_hook = [];
2023-03-15 20:40:49 +08:00
/**
* Load configuration array from JSON file
*
* @param string $config The configuration name (ext, lib, source, pkg, pre-built)
* @param null|string $config_dir Optional custom config directory
* @return array The loaded configuration array
2023-03-15 20:40:49 +08:00
* @throws FileSystemException
*/
2024-01-29 09:42:08 +08:00
public static function loadConfigArray(string $config, ?string $config_dir = null): array
2023-03-15 20:40:49 +08:00
{
2024-07-07 20:45:18 +08:00
$whitelist = ['ext', 'lib', 'source', 'pkg', 'pre-built'];
2023-03-15 20:40:49 +08:00
if (!in_array($config, $whitelist)) {
throw new FileSystemException('Reading ' . $config . '.json is not allowed');
}
2024-01-29 09:42:08 +08:00
$tries = $config_dir !== null ? [FileSystem::convertPath($config_dir . '/' . $config . '.json')] : [
2023-03-15 20:40:49 +08:00
WORKING_DIR . '/config/' . $config . '.json',
ROOT_DIR . '/config/' . $config . '.json',
];
foreach ($tries as $try) {
if (file_exists($try)) {
$json = json_decode(self::readFile($try), true);
if (!is_array($json)) {
throw new FileSystemException('Reading ' . $try . ' failed');
}
return $json;
}
}
throw new FileSystemException('Reading ' . $config . '.json failed');
}
/**
* Read file contents and throw exception on failure
2023-03-15 20:40:49 +08:00
*
* @param string $filename The file path to read
* @return string The file contents
2023-03-15 20:40:49 +08:00
* @throws FileSystemException
*/
public static function readFile(string $filename): string
{
// logger()->debug('Reading file: ' . $filename);
$r = file_get_contents(self::convertPath($filename));
if ($r === false) {
throw new FileSystemException('Reading file ' . $filename . ' failed');
}
return $r;
}
/**
* Replace string content in file
*
* @param string $filename The file path
* @param mixed $search The search string
* @param mixed $replace The replacement string
* @return false|int Number of replacements or false on failure
2023-03-15 20:40:49 +08:00
* @throws FileSystemException
*/
2023-10-14 11:22:46 +08:00
public static function replaceFileStr(string $filename, mixed $search = null, mixed $replace = null): false|int
2023-03-15 20:40:49 +08:00
{
return self::replaceFile($filename, REPLACE_FILE_STR, $search, $replace);
}
/**
* Replace content in file using regex
*
* @param string $filename The file path
* @param mixed $search The regex pattern
* @param mixed $replace The replacement string
* @return false|int Number of replacements or false on failure
* @throws FileSystemException
*/
2023-10-14 11:22:46 +08:00
public static function replaceFileRegex(string $filename, mixed $search = null, mixed $replace = null): false|int
{
return self::replaceFile($filename, REPLACE_FILE_PREG, $search, $replace);
}
/**
* Replace content in file using custom callback
*
* @param string $filename The file path
* @param mixed $callback The callback function
* @return false|int Number of replacements or false on failure
* @throws FileSystemException
*/
2023-10-14 11:22:46 +08:00
public static function replaceFileUser(string $filename, mixed $callback = null): false|int
{
return self::replaceFile($filename, REPLACE_FILE_USER, $callback);
2023-03-15 20:40:49 +08:00
}
/**
* Get file extension from filename
2023-03-15 20:40:49 +08:00
*
* @param string $fn The filename
* @return string The file extension (without dot)
2023-03-15 20:40:49 +08:00
*/
public static function extname(string $fn): string
{
$parts = explode('.', basename($fn));
if (count($parts) < 2) {
return '';
}
return array_pop($parts);
}
/**
* Find command path in system PATH (similar to which command)
2023-03-15 20:40:49 +08:00
*
* @param string $name The command name
* @param array $paths Optional array of paths to search
* @return null|string The full path to the command or null if not found
2023-03-15 20:40:49 +08:00
*/
public static function findCommandPath(string $name, array $paths = []): ?string
{
if (!$paths) {
$paths = explode(PATH_SEPARATOR, getenv('PATH'));
}
if (PHP_OS_FAMILY === 'Windows') {
foreach ($paths as $path) {
foreach (['.exe', '.bat', '.cmd'] as $suffix) {
if (file_exists($path . DIRECTORY_SEPARATOR . $name . $suffix)) {
return $path . DIRECTORY_SEPARATOR . $name . $suffix;
}
}
}
return null;
}
foreach ($paths as $path) {
if (file_exists($path . DIRECTORY_SEPARATOR . $name)) {
return $path . DIRECTORY_SEPARATOR . $name;
}
}
return null;
}
/**
* Copy directory recursively
*
* @param string $from Source directory path
* @param string $to Destination directory path
* @throws RuntimeException
*/
2023-03-15 20:40:49 +08:00
public static function copyDir(string $from, string $to): void
{
2023-04-30 12:42:19 +08:00
$dst_path = FileSystem::convertPath($to);
$src_path = FileSystem::convertPath($from);
switch (PHP_OS_FAMILY) {
case 'Windows':
f_passthru('xcopy "' . $src_path . '" "' . $dst_path . '" /s/e/v/y/i');
break;
case 'Linux':
case 'Darwin':
case 'BSD':
2023-04-30 12:42:19 +08:00
f_passthru('cp -r "' . $src_path . '" "' . $dst_path . '"');
break;
2023-03-15 20:40:49 +08:00
}
}
2024-02-18 13:54:06 +08:00
/**
* Extract package archive to specified directory
*
* @param string $name Package name
* @param string $source_type Archive type (tar.gz, zip, etc.)
* @param string $filename Archive filename
* @param null|string $extract_path Optional extraction path
2024-02-18 13:54:06 +08:00
* @throws RuntimeException
* @throws FileSystemException
*/
public static function extractPackage(string $name, string $source_type, string $filename, ?string $extract_path = null): void
2024-02-18 13:54:06 +08:00
{
if ($extract_path !== null) {
// replace
$extract_path = self::replacePathVariable($extract_path);
$extract_path = self::isRelativePath($extract_path) ? (WORKING_DIR . '/' . $extract_path) : $extract_path;
} else {
$extract_path = PKG_ROOT_PATH . '/' . $name;
}
logger()->info("Extracting {$name} package to {$extract_path} ...");
2024-02-18 13:54:06 +08:00
$target = self::convertPath($extract_path);
if (!is_dir($dir = dirname($target))) {
self::createDir($dir);
}
try {
// extract wrapper command
self::extractWithType($source_type, $filename, $extract_path);
2024-02-18 13:54:06 +08:00
} catch (RuntimeException $e) {
if (PHP_OS_FAMILY === 'Windows') {
f_passthru('rmdir /s /q ' . $target);
} else {
2024-02-19 12:15:52 +08:00
f_passthru('rm -rf ' . $target);
2024-02-18 13:54:06 +08:00
}
throw new FileSystemException('Cannot extract package ' . $name, $e->getCode(), $e);
}
}
2023-03-15 20:40:49 +08:00
/**
* Extract source archive to source directory
2023-03-15 20:40:49 +08:00
*
* @param string $name Source name
* @param string $source_type Archive type (tar.gz, zip, etc.)
* @param string $filename Archive filename
* @param null|string $move_path Optional move path
2023-03-15 20:40:49 +08:00
* @throws FileSystemException
* @throws RuntimeException
*/
public static function extractSource(string $name, string $source_type, string $filename, ?string $move_path = null): void
2023-03-15 20:40:49 +08:00
{
2023-04-30 12:42:19 +08:00
// if source hook is empty, load it
if (self::$_extract_hook === []) {
SourcePatcher::init();
}
$move_path = match ($move_path) {
null => SOURCE_PATH . '/' . $name,
default => self::isRelativePath($move_path) ? (SOURCE_PATH . '/' . $move_path) : $move_path,
};
$target = self::convertPath($move_path);
logger()->info("Extracting {$name} source to {$target}" . ' ...');
2024-02-18 13:54:06 +08:00
if (!is_dir($dir = dirname($target))) {
self::createDir($dir);
}
2023-03-15 20:40:49 +08:00
try {
self::extractWithType($source_type, $filename, $move_path);
2024-06-30 22:34:27 +08:00
self::emitSourceExtractHook($name, $target);
2023-03-15 20:40:49 +08:00
} catch (RuntimeException $e) {
if (PHP_OS_FAMILY === 'Windows') {
2024-02-18 13:54:06 +08:00
f_passthru('rmdir /s /q ' . $target);
2023-03-15 20:40:49 +08:00
} else {
2024-02-19 12:15:52 +08:00
f_passthru('rm -rf ' . $target);
2023-03-15 20:40:49 +08:00
}
2024-02-18 13:54:06 +08:00
throw new FileSystemException('Cannot extract source ' . $name . ': ' . $e->getMessage(), $e->getCode(), $e);
2023-03-15 20:40:49 +08:00
}
}
/**
* Convert path to system-specific format
2023-03-15 20:40:49 +08:00
*
* @param string $path The path to convert
* @return string The converted path
2023-03-15 20:40:49 +08:00
*/
public static function convertPath(string $path): string
{
if (str_starts_with($path, 'phar://')) {
return $path;
}
return str_replace('/', DIRECTORY_SEPARATOR, $path);
}
/**
* Convert Windows path to MinGW format
*
* @param string $path The Windows path
* @return string The MinGW format path
*/
public static function convertWinPathToMinGW(string $path): string
{
if (preg_match('/^[A-Za-z]:/', $path)) {
$path = '/' . strtolower(substr($path, 0, 1)) . '/' . str_replace('\\', '/', substr($path, 2));
}
return $path;
}
2023-03-15 20:40:49 +08:00
/**
* Scan directory files recursively
2023-03-15 20:40:49 +08:00
*
* @param string $dir Directory to scan
* @param bool $recursive Whether to scan recursively
* @param bool|string $relative Whether to return relative paths
* @param bool $include_dir Whether to include directories in result
* @return array|false Array of files or false on failure
2023-03-15 20:40:49 +08:00
*/
2023-10-14 11:22:46 +08:00
public static function scanDirFiles(string $dir, bool $recursive = true, bool|string $relative = false, bool $include_dir = false): array|false
2023-03-15 20:40:49 +08:00
{
$dir = self::convertPath($dir);
if (!is_dir($dir)) {
return false;
}
logger()->debug('scanning directory ' . $dir);
$scan_list = scandir($dir);
if ($scan_list === false) {
logger()->warning('Scan dir failed, cannot scan directory: ' . $dir);
return false;
}
$list = [];
// 将 relative 置为相对目录的前缀
if ($relative === true) {
$relative = $dir;
}
// 遍历目录
foreach ($scan_list as $v) {
// Unix 系统排除这俩目录
if ($v == '.' || $v == '..') {
continue;
}
$sub_file = self::convertPath($dir . '/' . $v);
if (is_dir($sub_file) && $recursive) {
# 如果是 目录 且 递推 , 则递推添加下级文件
$list = array_merge($list, self::scanDirFiles($sub_file, $recursive, $relative));
} elseif (is_file($sub_file) || is_dir($sub_file) && !$recursive && $include_dir) {
# 如果是 文件 或 (是 目录 且 不递推 且 包含目录)
if (is_string($relative) && mb_strpos($sub_file, $relative) === 0) {
$list[] = ltrim(mb_substr($sub_file, mb_strlen($relative)), '/\\');
} elseif ($relative === false) {
$list[] = $sub_file;
}
}
}
return $list;
}
/**
* Get PSR-4 classes from directory
2023-03-18 17:32:21 +08:00
*
* @param string $dir Directory to scan
* @param string $base_namespace Base namespace
* @param mixed $rule Optional filtering rule
* @param bool|string $return_path_value Whether to return path as value
* @return array Array of class names or class=>path pairs
2023-03-15 20:40:49 +08:00
*/
2023-03-18 17:32:21 +08:00
public static function getClassesPsr4(string $dir, string $base_namespace, mixed $rule = null, bool|string $return_path_value = false): array
2023-03-15 20:40:49 +08:00
{
$classes = [];
$files = FileSystem::scanDirFiles($dir, true, true);
if ($files === false) {
throw new FileSystemException('Cannot scan dir files during get classes psr-4 from dir: ' . $dir);
}
foreach ($files as $v) {
$pathinfo = pathinfo($v);
if (($pathinfo['extension'] ?? '') == 'php') {
if ($rule === null) {
2023-03-15 20:40:49 +08:00
if (file_exists($dir . '/' . $pathinfo['basename'] . '.ignore')) {
continue;
}
if (mb_substr($pathinfo['basename'], 0, 7) == 'global_' || mb_substr($pathinfo['basename'], 0, 7) == 'script_') {
continue;
}
} elseif (is_callable($rule) && !$rule($dir, $pathinfo)) {
continue;
}
$dirname = $pathinfo['dirname'] == '.' ? '' : (str_replace('/', '\\', $pathinfo['dirname']) . '\\');
$class_name = $base_namespace . '\\' . $dirname . $pathinfo['filename'];
if (is_string($return_path_value)) {
$classes[$class_name] = $return_path_value . '/' . $v;
} else {
$classes[] = $class_name;
}
}
}
return $classes;
}
/**
* Remove directory recursively
2023-03-15 20:40:49 +08:00
*
* @param string $dir Directory to remove
* @return bool Success status
2023-03-15 20:40:49 +08:00
*/
2023-04-15 18:46:02 +08:00
public static function removeDir(string $dir): bool
2023-03-15 20:40:49 +08:00
{
$dir = FileSystem::convertPath($dir);
2023-04-15 18:46:02 +08:00
logger()->debug('Removing path recursively: "' . $dir . '"');
2024-02-16 18:57:32 +08:00
if (!file_exists($dir)) {
logger()->debug('Scan dir failed, no such file or directory.');
return false;
}
2023-04-15 18:46:02 +08:00
if (!is_dir($dir)) {
2024-02-16 18:57:32 +08:00
logger()->warning('Scan dir failed, not directory.');
2023-04-15 18:46:02 +08:00
return false;
2023-03-15 20:40:49 +08:00
}
2023-04-15 18:46:02 +08:00
logger()->debug('scanning directory ' . $dir);
// 套上 zm_dir
$scan_list = scandir($dir);
if ($scan_list === false) {
logger()->warning('Scan dir failed, cannot scan directory: ' . $dir);
return false;
}
// 遍历目录
foreach ($scan_list as $v) {
// Unix 系统排除这俩目录
if ($v == '.' || $v == '..') {
continue;
}
$sub_file = self::convertPath($dir . '/' . $v);
if (is_dir($sub_file)) {
# 如果是 目录 且 递推 , 则递推添加下级文件
if (!self::removeDir($sub_file)) {
return false;
}
} elseif (is_link($sub_file) || is_file($sub_file)) {
if (!unlink($sub_file)) {
return false;
}
}
2023-03-15 20:40:49 +08:00
}
if (is_link($dir)) {
return unlink($dir);
}
2023-04-15 18:46:02 +08:00
return rmdir($dir);
2023-03-15 20:40:49 +08:00
}
/**
* Create directory recursively
*
* @param string $path Directory path to create
* @throws FileSystemException
*/
public static function createDir(string $path): void
{
2023-04-15 18:46:02 +08:00
if (!is_dir($path) && !f_mkdir($path, 0755, true) && !is_dir($path)) {
2024-01-10 21:08:25 +08:00
throw new FileSystemException(sprintf('Unable to create dir: %s', $path));
}
}
2023-03-29 21:39:36 +08:00
/**
* Write content to file
*
* @param string $path File path
* @param mixed $content Content to write
* @param mixed ...$args Additional arguments passed to file_put_contents
* @return bool|int|string Result of file writing operation
* @throws FileSystemException
*/
2023-10-14 11:22:46 +08:00
public static function writeFile(string $path, mixed $content, ...$args): bool|int|string
2023-03-29 21:39:36 +08:00
{
2024-01-10 21:08:25 +08:00
$dir = pathinfo(self::convertPath($path), PATHINFO_DIRNAME);
2023-03-29 21:39:36 +08:00
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
throw new FileSystemException('Write file failed, cannot create parent directory: ' . $dir);
}
return file_put_contents($path, $content, ...$args);
}
/**
* Reset directory by removing and recreating it
*
* @param string $dir_name Directory name
* @throws FileSystemException
*/
public static function resetDir(string $dir_name): void
{
$dir_name = self::convertPath($dir_name);
if (is_dir($dir_name)) {
self::removeDir($dir_name);
}
self::createDir($dir_name);
}
2023-04-30 12:42:19 +08:00
/**
* Add source extraction hook
*
* @param string $name Source name
* @param callable $callback Callback function
*/
public static function addSourceExtractHook(string $name, callable $callback): void
2023-04-30 12:42:19 +08:00
{
self::$_extract_hook[$name][] = $callback;
}
2023-07-20 01:15:28 +08:00
/**
* Check if path is relative
2023-07-20 01:15:28 +08:00
*
* @param string $path Path to check
* @return bool True if path is relative
2023-07-20 01:15:28 +08:00
*/
public static function isRelativePath(string $path): bool
{
if (DIRECTORY_SEPARATOR === '\\') {
return !(strlen($path) > 2 && ctype_alpha($path[0]) && $path[1] === ':');
}
return strlen($path) > 0 && $path[0] !== '/';
}
/**
* Replace path variables with actual values
*
* @param string $path Path with variables
* @return string Path with replaced variables
*/
2024-02-18 13:54:06 +08:00
public static function replacePathVariable(string $path): string
{
$replacement = [
'{pkg_root_path}' => PKG_ROOT_PATH,
2024-10-03 10:44:49 +08:00
'{php_sdk_path}' => getenv('PHP_SDK_PATH') ? getenv('PHP_SDK_PATH') : WORKING_DIR . '/php-sdk-binary-tools',
2024-02-18 13:54:06 +08:00
'{working_dir}' => WORKING_DIR,
'{download_path}' => DOWNLOAD_PATH,
'{source_path}' => SOURCE_PATH,
];
return str_replace(array_keys($replacement), array_values($replacement), $path);
}
/**
* Create backup of file
*
* @param string $path File path
* @return string Backup file path
*/
public static function backupFile(string $path): string
{
copy($path, $path . '.bak');
return $path . '.bak';
}
/**
* Restore file from backup
*
* @param string $path Original file path
*/
public static function restoreBackupFile(string $path): void
{
if (!file_exists($path . '.bak')) {
throw new RuntimeException('Cannot find bak file for ' . $path);
}
copy($path . '.bak', $path);
unlink($path . '.bak');
}
/**
* Remove file if it exists
*
* @param string $string File path
*/
public static function removeFileIfExists(string $string): void
{
$string = self::convertPath($string);
if (file_exists($string)) {
unlink($string);
}
}
/**
* Replace line in file that contains specific string
*
* @param string $file File path
* @param string $find String to find in line
* @param string $line New line content
* @return false|int Number of replacements or false on failure
* @throws FileSystemException
*/
public static function replaceFileLineContainsString(string $file, string $find, string $line): false|int
{
$lines = file($file);
if ($lines === false) {
throw new FileSystemException('Cannot read file: ' . $file);
}
foreach ($lines as $key => $value) {
if (str_contains($value, $find)) {
$lines[$key] = $line . PHP_EOL;
}
}
return file_put_contents($file, implode('', $lines));
}
2024-02-18 13:54:06 +08:00
/**
* @throws RuntimeException
* @throws FileSystemException
*/
private static function extractArchive(string $filename, string $target): void
{
// Create base dir
if (f_mkdir(directory: $target, recursive: true) !== true) {
throw new FileSystemException('create ' . $target . ' dir failed');
}
if (!file_exists($filename)) {
throw new FileSystemException('File not exists');
}
2024-02-18 13:54:06 +08:00
if (in_array(PHP_OS_FAMILY, ['Darwin', 'Linux', 'BSD'])) {
match (self::extname($filename)) {
'tar', 'xz', 'txz' => f_passthru("tar -xf {$filename} -C {$target} --strip-components 1"),
'tgz', 'gz' => f_passthru("tar -xzf {$filename} -C {$target} --strip-components 1"),
'bz2' => f_passthru("tar -xjf {$filename} -C {$target} --strip-components 1"),
'zip' => f_passthru("unzip {$filename} -d {$target}"),
default => throw new FileSystemException('unknown archive format: ' . $filename),
};
} elseif (PHP_OS_FAMILY === 'Windows') {
// use php-sdk-binary-tools/bin/7za.exe
2024-10-03 10:44:49 +08:00
$_7z = self::convertPath(getenv('PHP_SDK_PATH') . '/bin/7za.exe');
// Windows notes: I hate windows tar.......
// When extracting .tar.gz like libxml2, it shows a symlink error and returns code[1].
// Related posts: https://answers.microsoft.com/en-us/windows/forum/all/tar-on-windows-fails-to-extract-archive-containing/0ee9a7ea-9b1f-4fef-86a9-5d9dc35cea2f
// And MinGW tar.exe cannot work on temporarily storage ??? (GitHub Actions hosted runner)
// Yeah, I will be an MS HATER !
2024-02-18 13:54:06 +08:00
match (self::extname($filename)) {
'tar' => f_passthru("tar -xf {$filename} -C {$target} --strip-components 1"),
'xz', 'txz', 'gz', 'tgz', 'bz2' => cmd()->execWithResult("\"{$_7z}\" x -so {$filename} | tar -f - -x -C \"{$target}\" --strip-components 1"),
2024-02-18 13:54:06 +08:00
'zip' => f_passthru("\"{$_7z}\" x {$filename} -o{$target} -y"),
default => throw new FileSystemException("unknown archive format: {$filename}"),
};
}
}
/**
* @throws FileSystemException
*/
2023-10-14 11:22:46 +08:00
private static function replaceFile(string $filename, int $replace_type = REPLACE_FILE_STR, mixed $callback_or_search = null, mixed $to_replace = null): false|int
{
logger()->debug('Replacing file with type[' . $replace_type . ']: ' . $filename);
$file = self::readFile($filename);
switch ($replace_type) {
case REPLACE_FILE_STR:
default:
$file = str_replace($callback_or_search, $to_replace, $file);
break;
case REPLACE_FILE_PREG:
$file = preg_replace($callback_or_search, $to_replace, $file);
break;
case REPLACE_FILE_USER:
$file = $callback_or_search($file);
break;
}
return file_put_contents($filename, $file);
}
2024-06-30 22:34:27 +08:00
private static function emitSourceExtractHook(string $name, string $target): void
2023-04-30 12:42:19 +08:00
{
foreach ((self::$_extract_hook[$name] ?? []) as $hook) {
2024-06-30 22:34:27 +08:00
if ($hook($name, $target) === true) {
2023-04-30 12:42:19 +08:00
logger()->info('Patched source [' . $name . '] after extracted');
}
}
}
private static function extractWithType(string $source_type, string $filename, string $extract_path): void
{
logger()->debug('Extracting source [' . $source_type . ']: ' . $filename);
/* @phpstan-ignore-next-line */
match ($source_type) {
SPC_SOURCE_ARCHIVE => self::extractArchive($filename, $extract_path),
SPC_SOURCE_GIT => self::copyDir(self::convertPath($filename), $extract_path),
// soft link to the local source
SPC_SOURCE_LOCAL => symlink(self::convertPath($filename), $extract_path),
};
}
2023-03-15 20:40:49 +08:00
}