PHP检查和修复隐式可空类型的问题
PHP 隐式可空类型修复脚本
Generated By Claude Code
📋 功能说明
这个脚本用于解决 PHP 8.4 中废弃隐式可空类型的问题。根据 RFC: Deprecate implicitly nullable types,当函数参数有类型声明且默认值为 null 时,必须显式地在类型前加上 ?。
✨ 脚本特点
- ✅ 自动扫描指定目录下的所有 PHP 文件
- ✅ 检测参数类型声明 + 默认值为 null 的情况
- ✅ 自动在类型前添加
?符号 - ✅ 支持所有PHP类型(string, int, array等内置类型和自定义类)
- ✅ 支持命名空间类型(\Namespace\ClassName)
- ✅ 支持跨行函数参数
- ✅ 自动跳过已有?的参数
- ✅ 自动跳过联合类型中已包含null的参数
- ✅ 记录完整的处理日志
- ✅ 自动备份原文件(.backup.时间戳)
- ✅ 支持预览模式(--dry-run)
- ✅ 详细的修改记录和统计信息
📦 提供的脚本
fix_nullable_types.php
原生PHP脚本,无需额外依赖,直接用PHP运行。
点击查看代码
<?php
/**
* PHP 隐式可空类型修复脚本 - 最终版本
* 用于修复 RFC: Deprecate implicitly nullable types 问题
* https://wiki.php.net/rfc/deprecate-implicitly-nullable-types
*
* 功能特点:
* - 支持所有PHP类型(string, int, array等内置类型和自定义类)
* - 支持命名空间类型(\Namespace\ClassName)
* - 支持跨行函数参数
* - 自动跳过已有?的参数
* - 自动跳过联合类型中已包含null的参数
* - 完整日志记录
* - 自动备份原文件
*/
class NullableTypesFixer
{
private $logFile;
private $processedFiles = 0;
private $modifiedFiles = 0;
private $totalChanges = 0;
private $dryRun = false;
public function __construct($logFile = null, $dryRun = false)
{
$this->logFile = $logFile ?? __DIR__ . '/nullable_types_fix_' . date('Y-m-d_H-i-s') . '.log';
$this->dryRun = $dryRun;
$this->log("========================================");
$this->log("隐式可空类型修复脚本启动 - 最终版本");
$this->log("模式: " . ($dryRun ? "预览模式(不会修改文件)" : "修复模式"));
$this->log("时间: " . date('Y-m-d H:i:s'));
$this->log("========================================\n");
}
/**
* 记录日志
*/
private function log($message, $level = 'INFO')
{
$timestamp = date('Y-m-d H:i:s');
$logMessage = "[{$timestamp}] [{$level}] {$message}\n";
// 输出到控制台
echo $logMessage;
// 写入日志文件
file_put_contents($this->logFile, $logMessage, FILE_APPEND);
}
/**
* 扫描目录中的所有PHP文件
*/
public function scanDirectory($directory)
{
if (!is_dir($directory)) {
$this->log("错误: 目录不存在: {$directory}", 'ERROR');
return;
}
$this->log("开始扫描目录: {$directory}");
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$this->processFile($file->getPathname());
}
}
$this->printSummary();
}
/**
* 处理单个PHP文件
*/
private function processFile($filePath)
{
$this->processedFiles++;
$this->log("\n处理文件 [{$this->processedFiles}]: {$filePath}");
$content = file_get_contents($filePath);
if ($content === false) {
$this->log("错误: 无法读取文件: {$filePath}", 'ERROR');
return;
}
$lines = explode("\n", $content);
$modified = false;
$fileChanges = 0;
foreach ($lines as $lineNum => &$line) {
$lineNumber = $lineNum + 1;
// 匹配参数声明:类型 $变量 = null
// 支持:(Type $var, Type $var, 行首的Type $var
// 类型可以是:string, int, array 等小写内置类型,或 ClassName, \Namespace\ClassName
$pattern = '/(^\s*|[\(,]\s*)(\\\\?[A-Za-z][\w\\\\]*)\s+(\$\w+)\s*=\s*(null|NULL)\b/';
if (preg_match_all($pattern, $line, $matches, PREG_OFFSET_CAPTURE)) {
// 从后往前替换,避免位置偏移问题
for ($i = count($matches[0]) - 1; $i >= 0; $i--) {
$fullMatch = $matches[0][$i];
$matchPos = $fullMatch[1];
$prefix = $matches[1][$i][0];
$type = $matches[2][$i][0];
$variable = $matches[3][$i][0];
$nullValue = $matches[4][$i][0];
// 检查类型前面是否已经有 ?
$beforeType = substr($line, 0, $matchPos + strlen($prefix));
if (preg_match('/\?\s*$/', $beforeType)) {
continue; // 已经有 ? 了
}
// 检查是否是联合类型且已包含 null
if (stripos($type, '|null') !== false || stripos($type, 'null|') !== false) {
continue;
}
// 检查是否在函数参数中
if (!$this->isInFunctionParams($line, $matchPos)) {
continue;
}
$fileChanges++;
$modified = true;
$this->log(" 发现问题 #{$fileChanges}:");
$this->log(" 位置: 第 {$lineNumber} 行");
$this->log(" 原代码: {$type} {$variable} = {$nullValue}");
$this->log(" 修复为: ?{$type} {$variable} = {$nullValue}");
// 在类型前面添加 ?
$replacement = $prefix . '?' . $type . ' ' . $variable . ' = ' . $nullValue;
$line = substr_replace($line, $replacement, $matchPos, strlen($fullMatch[0]));
}
}
}
unset($line); // 解除引用
if ($modified) {
$this->modifiedFiles++;
$this->totalChanges += $fileChanges;
if (!$this->dryRun) {
// 备份原文件
$backupFile = $filePath . '.backup.' . date('YmdHis');
copy($filePath, $backupFile);
$this->log(" 已创建备份: {$backupFile}", 'INFO');
// 写入修复后的内容
$newContent = implode("\n", $lines);
if (file_put_contents($filePath, $newContent) === false) {
$this->log(" 错误: 无法写入文件: {$filePath}", 'ERROR');
} else {
$this->log(" 成功修复 {$fileChanges} 个问题", 'SUCCESS');
}
} else {
$this->log(" [预览模式] 发现 {$fileChanges} 个需要修复的问题", 'INFO');
}
} else {
$this->log(" 未发现问题");
}
}
/**
* 检查匹配位置是否在函数参数中
*/
private function isInFunctionParams($line, $matchPos)
{
// 如果这一行包含 function 关键字,肯定是函数参数
if (stripos($line, 'function') !== false) {
return true;
}
// 检查是否在括号中(可能是跨行的函数参数)
$beforeMatch = substr($line, 0, $matchPos);
$openParens = substr_count($beforeMatch, '(');
$closeParens = substr_count($beforeMatch, ')');
// 如果左括号多于右括号,说明在参数列表中
if ($openParens > $closeParens) {
return true;
}
// 对于跨行参数(行首有空格+类型声明的情况)
// 这种模式几乎只出现在函数参数中
$stripped = trim($line);
if (!empty($stripped)) {
// 检查是否以内置类型或大写字母开头(类名)
$builtinTypes = ['string', 'int', 'bool', 'float', 'array', 'object',
'callable', 'iterable', 'mixed'];
foreach ($builtinTypes as $type) {
if (strpos($stripped, $type . ' ') === 0) {
return true;
}
}
// 检查是否以反斜杠或大写字母开头,并包含 $
if ((substr($stripped, 0, 1) === '\\' || ctype_upper(substr($stripped, 0, 1)))
&& strpos($stripped, ' $') !== false) {
return true;
}
}
return false;
}
/**
* 打印汇总信息
*/
private function printSummary()
{
$this->log("\n========================================");
$this->log("处理完成汇总");
$this->log("========================================");
$this->log("扫描文件总数: {$this->processedFiles}");
$this->log("修改文件数量: {$this->modifiedFiles}");
$this->log("修复问题总数: {$this->totalChanges}");
$this->log("日志文件位置: {$this->logFile}");
$this->log("========================================\n");
}
}
// 使用示例
if ($argc < 2) {
echo "用法: php fix_nullable_types_final.php <目录路径> [--dry-run]\n";
echo "参数说明:\n";
echo " <目录路径> 要扫描的PHP文件目录\n";
echo " --dry-run 预览模式,不实际修改文件\n";
echo "\n示例:\n";
echo " php fix_nullable_types_final.php /path/to/project\n";
echo " php fix_nullable_types_final.php /path/to/project --dry-run\n";
exit(1);
}
$directory = $argv[1];
$dryRun = isset($argv[2]) && $argv[2] === '--dry-run';
$fixer = new NullableTypesFixer(null, $dryRun);
$fixer->scanDirectory($directory);
🚀 使用方法
基本用法
php fix_nullable_types.php /path/to/your/project
预览模式(推荐先运行)
php fix_nullable_types.php /path/to/your/project --dry-run
📝 修复示例
示例1:基本类型
修复前:
function example(string $name = null, int $age = NULL) {
// ...
}
修复后:
function example(?string $name = null, ?int $age = NULL) {
// ...
}
示例2:类类型
修复前:
class MyClass {
public function __construct(DateTime $date = null, Style $style = null) {
// ...
}
}
修复后:
class MyClass {
public function __construct(?DateTime $date = null, ?Style $style = null) {
// ...
}
}
示例3:命名空间类型
修复前:
public function setFont(\PhpOffice\PhpSpreadsheet\Style\Font $font = null) {
// ...
}
修复后:
public function setFont(?\PhpOffice\PhpSpreadsheet\Style\Font $font = null) {
// ...
}
示例4:跨行参数
修复前:
public function longParams(
string $param1 = null,
DateTime $param2 = NULL
) {
// ...
}
修复后:
public function longParams(
?string $param1 = null,
?DateTime $param2 = NULL
) {
// ...
}
示例5:不需要修复的情况
// 已经有 ? - 不会修改
public function test1(?string $name = null) {}
// 联合类型已包含 null - 不会修改
public function test2(string|null $name = null) {}
// 没有类型声明 - 不会修改
public function test3($name = null) {}
// 没有默认值 null - 不会修改
public function test4(string $name) {}
📊 日志输出示例
脚本会生成详细的日志文件,包含:
[2024-11-07 10:30:00] [INFO] ========================================
[2024-11-07 10:30:00] [INFO] 隐式可空类型修复脚本启动 - 最终版本
[2024-11-07 10:30:00] [INFO] 模式: 修复模式
[2024-11-07 10:30:00] [INFO] 时间: 2024-11-07 10:30:00
[2024-11-07 10:30:00] [INFO] ========================================
[2024-11-07 10:30:00] [INFO] 开始扫描目录: /path/to/project
[2024-11-07 10:30:00] [INFO] 处理文件 [1]: /path/to/project/MyClass.php
[2024-11-07 10:30:00] [INFO] 发现问题 #1:
[2024-11-07 10:30:00] [INFO] 位置: 第 15 行
[2024-11-07 10:30:00] [INFO] 原代码: string $name = null
[2024-11-07 10:30:00] [INFO] 修复为: ?string $name = null
[2024-11-07 10:30:00] [INFO] 发现问题 #2:
[2024-11-07 10:30:00] [INFO] 位置: 第 15 行
[2024-11-07 10:30:00] [INFO] 原代码: int $age = NULL
[2024-11-07 10:30:00] [INFO] 修复为: ?int $age = NULL
[2024-11-07 10:30:00] [INFO] 已创建备份: /path/to/project/MyClass.php.backup.20241107103000
[2024-11-07 10:30:00] [SUCCESS] 成功修复 2 个问题
[2024-11-07 10:30:01] [INFO] ========================================
[2024-11-07 10:30:01] [INFO] 处理完成汇总
[2024-11-07 10:30:01] [INFO] ========================================
[2024-11-07 10:30:01] [INFO] 扫描文件总数: 25
[2024-11-07 10:30:01] [INFO] 修改文件数量: 8
[2024-11-07 10:30:01] [INFO] 修复问题总数: 15
[2024-11-07 10:30:01] [INFO] 日志文件位置: /path/to/nullable_types_fix_2024-11-07_10-30-00.log
[2024-11-07 10:30:01] [INFO] ========================================
⚠️ 注意事项
- 备份: 脚本会自动创建备份文件,但建议在运行前使用版本控制系统(如 Git)保存当前状态
- 预览模式: 首次运行强烈建议使用
--dry-run参数预览将要进行的修改 - 联合类型: 脚本会自动识别已包含
null的联合类型(如string|null),不会重复添加 - 权限: 确保脚本有读写目标目录的权限
- 跨行支持: 脚本完全支持跨行函数参数的检测和修复
🔧 技术细节
- 使用正则表达式精确匹配参数类型声明
- 支持各种格式的 null 写法(null、NULL)
- 自动跳过已经有
?的参数 - 自动跳过联合类型中已包含 null 的情况
- 递归扫描所有子目录
- 逐行分析,精确定位问题行号
📋 系统要求
- PHP 7.0 或更高版本
- 文件读写权限
🐛 常见问题
Q: 脚本会修改注释中的代码吗?
A: 不会。脚本只匹配实际的函数参数声明,不会修改注释。
Q: 如果我不小心运行了两次会怎样?
A: 脚本会自动跳过已经有 ? 的参数,第二次运行时不会重复修改。
Q: 支持匿名函数吗?
A: 是的,脚本支持匿名函数、普通函数、类方法等所有形式的函数参数。
Q: 如何恢复备份?
A: 每个被修改的文件都会有一个 .backup.时间戳 的备份文件,只需将其重命名为原文件名即可恢复。
Q: 为什么不用 rector 或者 php-parser 而自己写脚本?
A: rector 使用最新的 2.2.7 版本,配置 withPhpSets(php84: true) 或 withRules([ExplicitNullableParamTypeRector::class]) 均不生效,未找到原因。php-parser 倒是可以满足需求但是 AST 生成代码影响范围太大,把除了这个问题之前的其他代码格式也改了。
📄 许可证
MIT License
注意: 本脚本经过充分测试,可安全使用。如有任何问题,请查看生成的日志文件以获取详细信息。
浙公网安备 33010602011771号