<?php
/**
* KePHP, Keep PHP easy!
*
* @license https://opensource.org/licenses/MIT
* @copyright Copyright 2015-2018 KePHP Authors All Rights Reserved
* @link http://kephp.com/utils ( https://git.oschina.net/kephp/kephp-utils )
* @author 曾建凯 <janpoem@163.com>
*/
namespace Ke\Utils;
/**
* 路径处理助手
*
* 这个的方法方法实现,参数为 `$ds` 的,表明该参数必须是目录分隔符,也即 `PathHelper::DS_WIN` 或 `PathHelper::DS_UNIX` 其中之一。
*
* 参数命名为 `$spr` ,表明该参数可以指定除了目录分隔符以外的字符串,但因为处理机制的问题,该参数只支持 1 位长度字符串,超出的会截取掉保留首位的字符串。
*
* @package Ke\Utils
*/
class PathHelper
{
/** @var string 默认的路径处理时常见的噪音字符 */
const DEFAULT_PATH_NOISE = '/\\ ';
/** @var string Windows 风格的目录分隔符 */
const DS_WIN = '\\';
/** @var string Unix 或者 Linux 风格的目录分隔符 */
const DS_UNIX = '/';
/** @var int 净化路径值时,.././ 删除 - 默认值 */
const DOT_REMOVE = 0b00;
/** @var int 净化路径值时,.././ 维持原来状态 */
const DOT_ORIGINAL = 0b10;
/** @var int 净化路径值时,.././ 常规化 - ../ 向前递进一个目录 */
const DOT_NORMALIZE = 0b11;
/** @var int 净化路径值时,最左边(开头)的目录分隔符 删除 - 默认值 */
const LEFT_REMOVE = 0b0000;
/** @var int 净化路径值时,最左边(开头)的目录分隔符 维持原状 */
const LEFT_ORIGINAL = 0b1000;
/** @var int 净化路径值时,最左边(开头)的目录分隔符 强制填充 */
const LEFT_FILL = 0b1100;
/** @var int 净化路径值时,最右边(末尾)的目录分隔符 删除 - 默认值 */
const RIGHT_REMOVE = 0b000000;
/** @var int 净化路径值时,最右边(末尾)的目录分隔符 维持原状 */
const RIGHT_ORIGINAL = 0b100000;
/** @var int 净化路径值时,最右边(末尾)的目录分隔符 强制填充 */
const RIGHT_FILL = 0b110000;
const LR_REMOVE = 0b00;
/** @var int 净化路径值时,头尾的目录分隔符 维持原状 */
const LR_ORIGINAL = self::LEFT_ORIGINAL | self::RIGHT_ORIGINAL;
/** @var int 净化路径值时,头尾的目录分隔符 强制填充 */
const LR_FILL = self::LEFT_FILL | self::RIGHT_FILL;
/** @var int 净化路径值时,全部有效的二进制取值 */
const PURGE_ALL_VALUE = 0b111111;
/** @var int 默认的 purge 处理模式,该参数预留给实际项目中重载时,调节参数使用。 */
protected $purgeMode = self::DOT_REMOVE | self::LEFT_REMOVE | self::RIGHT_REMOVE;
/** @var string 默认的目录分隔符 */
protected $ds = self::DS_UNIX;
/** @var string 类实例默认的路径噪音字符,这个 $noise 可被后继的类重载,使用上和 `PathHelper::DEFAULT_PATH_NOISE` 不同 */
protected $noise = self::DEFAULT_PATH_NOISE;
/** @var array 已经生成的 `路径 => 绝对路径` 的映射,这里会存在 访问名不同(绝对路径或相对路径),但绝对路径的值相同可能性,所以以访问的路径名作为 key */
protected $pathMaps = [];
/**
* 生成一个路径的绝对路径(php的realpath方法)
*
* - 调整默认 `realpath` 函数不支持 phar 包内文件的结果。
* - 统一将所有的目录分隔符转化为统一的目录分隔符(根据 `$this->ds`)。
*
* @todo 在unix下,不存在的路径是否应该返回false
*
* @param string $path
*
* @return string
*/
public function absolute(string $path): string
{
if (!isset($this->pathMaps[$path])) {
$realPath = realpath($path);
if ($realPath === false) {
if (file_exists($path)) {
$realPath = $path;
}
}
if ($realPath !== false) {
if (strpos($realPath, self::DS_WIN) !== false)
$realPath = str_replace(self::DS_WIN, self::DS_UNIX, $realPath);
}
$this->pathMaps[$path] = $realPath;
}
return $this->pathMaps[$path];
}
/**
* 净化路径值
*
* 1. 根据指定的 $spr 替换为统一的目录分隔符
* 2. 去掉多余重复的 $spr
* 3. 依据 $dotMode 过滤(或不处理) 路径值中的 /../
* 4. 依据 $leftMode 是否补充(或强制填充,或强制截取) 最开始的目录分隔符(尤其是对于绝对路径),Windows风格的路径值不受该参数影响。
*
* 基于上一个版本的kephp的全局函数 purge_path 方法,优化了 $mode 参数。
*
* 去掉了默认的 urldecode ,所以处理 Url 的路径时,请自行先行进行 decode。
*
* $mode 用于说明一个路径净化处理时的三个处理模式:
*
* - dotMode - 路径中的 ../ ./ 的处理模式:
* - `PathHelper::DOT_REMOVE` - 默认值,强制删除路径中点,忽略其意义
* - `PathHelper::DOT_ORIGINAL` - 维持原状,不做处理。
* - `PathHelper::DOT_NORMALIZE` - 常规化处理,即将 ../ 向前一层的目录递进, ./ 则删除。
* - leftMode & rightMode - 路径头尾的目录分隔符的处理方式:
* - `PathHelper::LEFT_REMOVE` `PathHelper::RIGHT_REMOVE` - 默认值,强制去除最左边或最右边的分隔符
* - `PathHelper::LEFT_ORIGINAL` `PathHelper::RIGHT_ORIGINAL` - 最左边或最右边的分隔符维持原状(原来有就有,原来没有就没有)
* - `PathHelper::LEFT_FILL` `PathHelper::RIGHT_FILL` - 最左边或最右边的分隔符强制填充(原来没有也会强制加上)
*
* `PathHelper::LR_REMOVE = PathHelper::LEFT_REMOVE | PathHelper::RIGHT_REMOVE ` - 左右两边都删除
* `PathHelper::LR_ORIGINAL = PathHelper::LEFT_ORIGINAL | PathHelper::RIGHT_ORIGINAL` - 左右两边都维持原状
* `PathHelper::LR_FILL = PathHelper::LEFT_FILL | PathHelper::RIGHT_FILL ` - 左右两边都填充
*
* ```php
* $mode = PathHelper::DOT_ORIGINAL | PathHelper::LR_REMOVE; // 路径中的点维持原状,左右两边的分隔符删除
* $mode = PathHelper::DOT_REMOVE | PathHelper::LR_FILL; // 路径中的点强制删除,左右两边的分隔符强制填充
* ```
*
* 调用示例:
*
* ```php
* $helper = new PathHelper();
* $helper->purge('a/b/c', PathHelper::LR_FILL); // => /a/b/c/
* $helper->purge('-a---b---..--.---c-d---', PathHelper::DOT_REMOVE | PathHelper::LR_REMOVE, '-'); // => a-b-c-d
* ```
*
* 更多示例代码,请参阅 `Test_PathHelper` 。
*
* 该方法支持通过 $spr 指定其他的分隔符,但分隔符只支持 1 位长度字符串,如果超出,会只取该字符的 0 的字符值作为 $spr。
*
* @param string $path 要净化处理的路径值
* @param int|null $mode 净化的处理模式,参考 PathHelper 的常量说明
* @param string|null $spr 目录分隔符,可以指定其他的分隔符
* @param string|null $trimNoise 路径处理的噪音字符串
*
* @return string
*/
public function purge(string $path, int $mode = null, string $spr = null, string $trimNoise = null): string
{
if (!isset($mode) || $mode < 0)
$mode = $this->purgeMode;
$mode = $mode & PathHelper::PURGE_ALL_VALUE;
$rightMode = ($mode >> 4) << 4;
$leftMode = (($mode ^ $rightMode) >> 2) << 2;
$dotMode = $mode ^ $rightMode ^ $leftMode;
// 过滤$spr,基于spr来确定noise
if (empty($spr)) {
$spr = $this->ds;
} else if (mb_strlen($spr) > 0) {
// 只取 $spr 的第一个字符
$spr = mb_substr($spr, 0, 1);
}
// 这里要使用常量,不能使用类变量
if (empty($trimNoise)) $trimNoise = self::DEFAULT_PATH_NOISE;
// 检查spr是否在noise中
if (strpos($trimNoise, $spr) === false) $trimNoise .= $spr;
// 其次,基于spr如果是目录分隔符,替换掉多余的winDS或者unixDS,确保路净只存在一种目录分隔符
if ($spr === self::DS_UNIX) {
$path = str_replace(self::DS_WIN, self::DS_UNIX, $path);
} else if ($spr === self::DS_WIN) {
$path = str_replace(self::DS_UNIX, self::DS_WIN, $path);
}
$isWinPath = false; // 是否windows风格的路径
$winHead = null; // windows路径头部
$isStartWithSpr = false; // 是否以spr为开头
$isEndWithSpr = mb_substr($path, -1) === $spr; // 是否以spr为结尾
if ($isWinPath = preg_match('#^([a-z]\:)[\/\\\\]#i', $path, $matches)) {
$size = mb_strlen($matches[1]);
$winHead = mb_substr($path, 0, $size); // 提取出 D:
$path = mb_substr($path, $size);
$isStartWithSpr = true; // 符合windows风格的路径名,必然是绝对路径
} else {
$isStartWithSpr = mb_substr($path, 0, 1) === $spr;
}
// 去掉路径两边的多余的噪音字符,这里必须确保,只有一个/处理为空字符
$path = trim($path, $trimNoise);
// dot 处理
if (!empty($path)) {
if ($dotMode === self::DOT_ORIGINAL) { // .././ 维持原状,不做处理
$path = preg_replace('#[' . preg_quote($spr) . ']+#', $spr, $path);
} else {
$temp = [];
foreach (explode($spr, $path) as $index => $segment) {
// 这些都去掉
if ($segment === '.' || $segment === $spr || empty($segment))
continue;
if ($segment === '..') {
if ($dotMode === self::DOT_NORMALIZE)
array_pop($temp);
continue;
}
$temp[] = $segment;
}
$path = implode($spr, $temp);
}
}
// 最后基于 leftMode or winStyle 重新还原路径
if ($isWinPath) {
$path = $winHead . $spr . $path;
} else {
if ($leftMode === self::LEFT_FILL || ($leftMode === self::LEFT_ORIGINAL && $isStartWithSpr)) {
$path = $spr . $path;
}
}
if ($path !== $spr && ($rightMode === self::RIGHT_FILL || ($rightMode === self::RIGHT_ORIGINAL && $isEndWithSpr))) {
$path .= $spr;
}
return $path;
}
/**
* 转换路径中的目录分隔符
*
* 该方法不支持 Unix 或 Windows 风格以外的目录分隔符。
*
* @param string $path 要转换的路径
* @param string|null $ds 目录分隔符,这里只允许是 Unix 或 Windows 风格的目录分隔符,不支持其他。
*
* @return string 返回统一转换过的路径名
*/
public function convertDirectorySeparator(string $path, string $ds = null): string
{
if (empty($path))
return '';
if (empty($ds) || ($ds !== self::DS_UNIX && $ds !== self::DS_WIN))
$ds = $this->ds;
$search = $ds === self::DS_UNIX ? self::DS_WIN : self::DS_UNIX;
if (strpos($path, $search) !== false)
$path = str_replace($search, $ds, $path);
return $path;
}
/**
* 指定一个路径,为该路径预备建立所需的目录(递归)
*
* - 如果该路径是一个文件路径,则建立该文件所需的目录(dirname)。
* - 如果是一个目录路径,则建立整个路径。
*
* @param string $path 一个要写入的文件路径,或者是一个目录的路径
* @param bool $isDir 说明 $path 是一个文件路径还是一个目录路径
* @param int $mode 创建目录的权限值
*
* @return false|string 返回所创建的(或者本身目录已经存在的)目录的绝对路径,如果创建失败或者传入的路径名有误,返回 false
*/
public function prepareDirectory(string $path, bool $isDir = false, $mode = 0755): string
{
$dir = $isDir ? $path : dirname($path);
if (!empty($dir) && $dir !== '.' && $dir !== self::DS_WIN && $dir !== self::DS_UNIX) {
if (!is_dir($dir)) {
if (!mkdir($dir, $mode, true)) {
return false;
}
}
return $this->absolute($dir);
}
return false;
}
/**
* 将一个路径分离(解析)出目录名、文件名、文件后缀名(强制转小写)、无后缀文件名
*
* - 该方法不包含purge,请先自行purge。
* - 该方法也不会统一转换目录的分隔符,请先自行转换。
* - 该方法解析路径时,以最右边(末尾)是否为一个目录分隔符,作为识别该路径是否是一个目录的路径,还是一个文件的路径。目录路径时,文件名、文件后缀名、无后缀文件名皆为 null。
* - 该方法提取 文件后缀名 的规则,为文件名部分最右边的 `.` 之后(不含 `.` )的字符串
* - 提取出的 文件后缀名 ,会强制转为小写保存(但解析出的 文件名 不会做此处理)
*
* ```php
* path('/var/log/'); // => 表示为一个目录路径,结果:['/var/log', null, null, null]
* path('/var/log'); // => 表示为一个文件路径,结果:['/var', ‘log’, null, 'log']
* path('/var/log/nginx.log'); // => 表示为一个文件路径,结果:['/var/log', ‘nginx.log’, 'log', 'nginx']
*
* // 文件名不会进行大小写转换处理,但是提取出来的后缀文件名,会强制转为小写
* path('/var/log/nginx.LOG'); // => 表示为一个文件路径,结果:['/var/log', ‘nginx.LOG’, 'log', 'nginx']
*
* // 后缀名匹配,为右匹配的模式
* path('/var/log/nginx.20180930.log'); // => 表示为一个文件路径,结果:['/var/log', ‘nginx.20180930.log’, 'log', 'nginx.20180930']
* ```
*
* 注意:由于风格的问题,`dirname` 强制去除了最末尾的 目录分隔符。
*
* @todo 后续会为PathHelper添加一个类属性,用于定制路径的处理风格。
*
* @param string $path
*
* @return array 返回数据格式:`[dirname, filename, extname, basename]`
*/
public function split(string $path): array
{
$return = [
null, // dirname - 目录名
null, // filename - 文件名
null, // extname - 文件后缀名
null, // basename - 无后缀文件名
];
if ($path !== '') {
if (preg_match('#^(?:(.*)[\/\\\\])?([^\/\\\\]+)?$#', $path, $matches)) {
if (!empty($matches[1])) {
// $return[0] = preg_replace('#(\/+)$#', '', $matches[1]);
$return[0] = rtrim($matches[1], $this->noise); // 目录
}
if (isset($matches[2]) && $matches[2] !== '') {
$return[1] = $matches[2]; // 文件名
if (($pos = strrpos($matches[2], '.')) > 0) {
// 文件后缀名
$return[2] = strtolower(substr($matches[2], $pos + 1));
// 去除后缀名的文件名
$return[3] = substr($matches[2], 0, $pos);
} else {
$return[3] = $matches[2];
}
}
}
}
return $return;
}
/**
* 侦测一个路径是否为包含phar协议的路径值
*
* @param string $path
*
* @return array 返回结果 `[移除phar的路径名, 是否phar]`
*/
public function detectPhar(string $path): array
{
$isPhar = false;
if (preg_match('#^phar://(.*)[\/\\\\]([^\/\\\\]+\.phar)#i', $path, $matches)) {
$path = $matches[1];
$isPhar = $matches[2];
} else if (preg_match('#^phar://(.*)#i', $path, $matches)) {
$path = $matches[1];
}
return [$path, $isPhar];
}
/**
* 比较两个路径,返回相同的部分
*
* 必须确保两个传入的路径都是被净化处理过的路径名,不包含类如/../,并且请确保传入的路径都有一致的目录分隔符。
* 本函数不会自动调用purge的函数,请调用前自己执行
*
* ```php
* compare_path('/aa/bb/cc', '/aa/bb/dd'); // => aa/bb
*
* // 这个函数还可以用于挑出两个字符串相同的部分
* compare_path('ab-cd-ef-gh-ij', 'ab-cd-ef-gh-abc', '-'); // => ab-cd-ef-gh
* ```
*
* @param string|null $source
* @param string|null $target
* @param string|null $delimiter
* @param string|null $noise
* @param string|null $prefix
*
* @return string
*/
public function compare(
string $source = null
,
string $target = null
,
string $delimiter = null
,
string $prefix = null
,
string $noise = null
): string {
// 处理分隔符
if (empty($delimiter)) {
$delimiter = $this->ds;
} else if (mb_strlen($delimiter) > 0) {
$delimiter = mb_substr($delimiter, 0, 1); // 只取 $delimiter 的第一个字符
}
// 噪音
if (empty($noise))
$noise = $this->noise;
// 补充一下噪音
$noise .= $delimiter;
// 过滤 $prefix
$prefix = $prefix ?? '';
// if (!empty($prefix))
// $prefix = trim($source, $noise);
// 不对prefix进行噪音过滤处理,所以请确保传入的prefix的准确性
if (empty($source) || empty($target))
return $prefix;
$source = trim($source, $noise);
$target = trim($target, $noise);
$result = [];
$splitSource = explode($delimiter, $source);
$splitTarget = explode($delimiter, $target);
if (!empty($splitSource) && !empty($splitTarget)) {
foreach ($splitSource as $index => $str) {
if (!isset($splitSource[$index]) ||
!isset($splitTarget[$index]) ||
strcasecmp($splitSource[$index], $splitTarget[$index]) !== 0
) {
break;
}
$result[] = $str;
}
}
if (!empty($result))
return $prefix . implode($delimiter, $result);
return $prefix;
}
}