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] ========================================

⚠️ 注意事项

  1. 备份: 脚本会自动创建备份文件,但建议在运行前使用版本控制系统(如 Git)保存当前状态
  2. 预览模式: 首次运行强烈建议使用 --dry-run 参数预览将要进行的修改
  3. 联合类型: 脚本会自动识别已包含 null 的联合类型(如 string|null),不会重复添加
  4. 权限: 确保脚本有读写目标目录的权限
  5. 跨行支持: 脚本完全支持跨行函数参数的检测和修复

🔧 技术细节

  • 使用正则表达式精确匹配参数类型声明
  • 支持各种格式的 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


注意: 本脚本经过充分测试,可安全使用。如有任何问题,请查看生成的日志文件以获取详细信息。

posted on 2025-11-07 14:48  可曾记起爱  阅读(2)  评论(0)    收藏  举报

导航