【PHP】引数のデフォルト値がnullかつ型にnullが含まれていない関数やメソッドを nikic/php-parser で見つけて修正する

  • 2024年12月13日
  • 2024年12月13日
  • PHP

 PHP8.4では引数のデフォルト値がnullにもかかわらず引数の型がnullを許容していない場合、非推奨エラーが発生します。これは例えば次のように起きます。

<?php

// これは string 型なのに null がデフォルトなのでエラー
function hasNullableArgStr(string $a = null): void
{
    echo $a;
}

// 型が未指定なのでエラーなし
function hasNullableArgUnknown($a = null): void
{
    echo $a;
}

// nullableなstring型なのでエラーなし
function hasNullableArgBestNullable(?string $a = null): void
{
    echo $a;
}

// 型がstringかnullを示す型なのでエラーなし
function hasNullableArgBestUnion(string|null $a = null): void
{
    echo $a;
}

上記を実行すると次のようになります。

Deprecated: hasNullableArgStr(): Implicitly marking parameter $a as nullable is deprecated, the explicit nullable type must be used instead in /in/XODNW on line 4

 型の不一致による非推奨エラーです。これを回避するには適切に ?string や string | null のように型をつければよいです。これを簡易に検知、修正する方法を紹介します。

 使うライブラリは nikic/php-parser です。これは PHP で PHP の言語構造を扱うライブラリです。これを使うと次のように PHP のソースコードを読み、関数やメソッドの引数の型を読み、型を改修したコードをダンプすることができます。
nikic/PHP-Parser: A PHP parser written in PHP

<?php

require 'vendor/autoload.php';

use PhpParser\Error;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter\Standard;

/**
 * 指定されたディレクトリ内のすべてのPHPファイルを再帰的にスキャンします。
 *
 * @param  string   $directory スキャン対象のディレクトリのパス
 * @return string[] スキャンされたPHPファイルのパスの配列
 */
function scanDirectory(string $directory): array
{
    $files = [];
    foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)) as $file) {
        if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
            $files[] = $file;
        }
    }

    return $files;
}

/**
 * PHPコードを解析し、nullableでない型ヒントに対してデフォルト値nullを持つ場合を修正します。
 *
 * @param  string $code 修正対象のPHPコード
 * @return array{0:string, 1:array<int, array<string, mixed>>, 2:array<int, array<string, mixed>>}|null
 *           修正されたコード、修正情報、警告情報を含む配列。
 *           - 0: 修正されたPHPコード(文字列)
 *           - 1: 修正情報の配列
 *           - 各要素は次のキーを含む連想配列
 *           - 'line': 修正された行番号(int)
 *           - 'before': 修正前の関数定義のシグネチャとボディ(string)
 *           - 'after': 修正後の関数定義のシグネチャとボディ(string)
 *           - 2: 警告情報の配列
 *           - 各要素は次のキーを含む連想配列
 *           - 'line': 警告が発生した行番号(int)
 *           - 'reason': 警告の理由(string)
 *           - 'code': 該当コードのプレビュー(string)
 *           修正が必要ない場合または解析エラーが発生した場合はnullを返します。
 */
function findAndFixInvalidNullDefaults(string $code): ?array
{
    $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
    try {
        $ast = $parser->parse($code);
    } catch (Error $e) {
        echo '解析エラー: ' . $e->getMessage() . "\n";

        return null; // 解析に失敗した場合はnullを返す
    }

    $traverser = new NodeTraverser();

    $traverser->addVisitor(new ParentAttributeVisitor());
    $fixer = new NullDefaultFixerVisitor();
    $traverser->addVisitor($fixer);

    $modifiedAst = $traverser->traverse($ast);

    // 修正または警告があった場合に結果を返す
    return $fixer->modified || ! empty($warnings)
        ? [$fixer->printer->prettyPrintFile($modifiedAst), $fixer->fixes, $fixer->warnings]
        : null;
}

/**
 * ASTのノードに親ノードを設定するためのVisitorクラス。
 */
class ParentAttributeVisitor extends NodeVisitorAbstract
{
    /**
     * ノードに親ノードの属性を設定します。
     *
     * @param Node $node 現在のノード
     */
    public function enterNode(Node $node): void
    {
        foreach ($node->getSubNodeNames() as $name) {
            $child = $node->$name;
            if ($child instanceof Node) {
                $child->setAttribute('parent', $node);
            } elseif (is_array($child)) {
                foreach ($child as $subChild) {
                    if ($subChild instanceof Node) {
                        $subChild->setAttribute('parent', $node);
                    }
                }
            }
        }
    }
}

/**
 * nullableでない型ヒントを修正し、必要に応じて警告を生成するVisitorクラス。
 */
class NullDefaultFixerVisitor extends NodeVisitorAbstract
{
    /** @var bool 修正の有無 */
    public bool $modified = false;
    /**
     * 修正情報を格納する配列。
     *
     * @var array<int, array<string, mixed>> 修正情報の配列
     *                                       - 'line': 修正された行番号(int)
     *                                       - 'before': 修正前の関数シグネチャとボディ(string)
     *                                       - 'after': 修正後の関数シグネチャとボディ(string)
     */
    public array $fixes = [];
    /**
     * 警告情報を格納する配列。
     *
     * @var array<int, array<string, mixed>> 警告情報の配列
     *                                       - 'line': 警告が発生した行番号(int)
     *                                       - 'reason': 警告の理由(string)
     *                                       - 'code': 該当コードのプレビュー(string)
     */
    public array $warnings = [];
    /**
     * コードを整形するためのPrettyPrinter。
     *
     * @var Standard
     */
    public Standard $printer;

    public function __construct()
    {
        $this->printer =  new Standard();
    }

    /**
     * 関数シグネチャを取得します。
     *
     * @param  Node\FunctionLike $functionNode 関数ノード
     * @return string            関数のシグネチャ
     */
    private function getFunctionSignature(Node\FunctionLike $functionNode): string
    {
        $name = isset($functionNode->name) && $functionNode->name instanceof Node\Identifier
            ? $functionNode->name->name
            : '(anonymous)';
        $params = array_map(
            fn ($param) => $this->printer->prettyPrint([$param]),
            $functionNode->getParams()
        );
        $returnType       = $functionNode->getReturnType();
        $returnTypeString = $returnType ? ': ' . $this->printer->prettyPrint([$returnType]) : '';

        return "function $name(" . implode(', ', $params) . ")$returnTypeString";
    }

    /**
     * 関数ボディのプレビューを取得します。
     *
     * @param  Node\FunctionLike $functionNode 関数ノード
     * @return string            関数ボディのプレビュー
     */
    private function getFunctionBodyPreview(Node\FunctionLike $functionNode): string
    {
        if (! $functionNode->getStmts()) {
            return ''; // 空の関数の場合は空文字列を返す
        }
        $bodyStatements   = array_slice($functionNode->getStmts(), 0, 3);
        $bodyPreview      = implode("\n", array_map(fn ($stmt) => $this->printer->prettyPrint([$stmt]), $bodyStatements));
        $bodyPreviewSlice = implode("\n", array_slice(explode("\n", $bodyPreview), 0, 3));

        $closeBrace = ($bodyPreviewSlice === $bodyPreview) ? "\n}" : "\n    ...";

        return "{\n" . preg_replace('/^/m', '    ', $bodyPreviewSlice) . $closeBrace;
    }

    /**
     * ノードを離れる際に修正を適用します。
     *
     * @param Node $node 現在のノード
     */
    public function leaveNode(Node $node): void
    {
        if (
            $node instanceof Node\Param
            && $node->default instanceof Node\Expr\ConstFetch
            && strtolower($node->default->name->toString()) === 'null'
        ) {
            $functionNode = $node->getAttribute('parent');
            if (! $functionNode instanceof Node\FunctionLike) {
                return; // 関数の外でデフォルト値がnullのパラメータがある場合は無視
            }

            $originalSignature = $this->getFunctionSignature($functionNode);
            $originalBody      = $this->getFunctionBodyPreview($functionNode);

            if ($node->type instanceof Node\Name || $node->type instanceof Node\Identifier) {
                // 単一型 → Nullable型に修正
                $node->type     = new Node\NullableType($node->type);
                $this->modified = true;
                $this->fixes[]  = [
                    'line'   => $node->getLine(),
                    'before' => $originalSignature . ' ' . $originalBody,
                    'after'  => $this->getFunctionSignature(
                        $functionNode
                    ) . ' ' . $this->getFunctionBodyPreview($functionNode),
                ];
            } elseif ($node->type instanceof Node\UnionType) {
                $containsNull = false;
                foreach ($node->type->types as $type) {
                    if (
                        ($type instanceof Node\Name || $type instanceof Node\Identifier)
                        && strtolower($type->toString()) === 'null'
                    ) {
                        $containsNull = true;
                        break;
                    }
                }
                if (! $containsNull) {
                    $node->type->types[] = new Node\Identifier('null');
                    $this->modified      = true;
                    $this->fixes[]       = [
                        'line'   => $node->getLine(),
                        'before' => $originalSignature . ' ' . $originalBody,
                        'after'  => $this->getFunctionSignature(
                            $functionNode
                        ) . ' ' . $this->getFunctionBodyPreview($functionNode),
                    ];
                }
            } elseif ($node->type instanceof Node\IntersectionType) {
                $this->warnings[] = [
                    'line'   => $node->getLine(),
                    'reason' => 'Intersection型が使用されており、Nullableへの修正ができません。',
                    'code'   => $originalSignature . ' ' . $originalBody,
                ];
            }
        }
    }
}

// コマンドラインオプションの解析
$options = getopt('d::h', ['directory::', 'dry-run', 'no-color', 'help']);
if (isset($options['h']) || isset($options['help'])) {
    echo "Options:\n";
    echo "  --directory, -d   必須。指定されたディレクトリ内のPHPファイルを処理します\n";
    echo "  --dry-run         実際にはファイルを書き換えずに、修正対象を表示します\n";
    echo "  --no-color        色付けを無効化します\n";
    echo "  --help, -h        このヘルプメッセージを表示します\n";
    exit(0);
}

$directory = $options['d'] ?? $options['directory'] ?? null;
if (! $directory) {
    echo "ディレクトリを指定してください。--directory オプションを使用します。\n";
    exit(1);
}

if (! is_dir($directory)) {
    echo "指定されたディレクトリが存在しません。\n";
    exit(1);
}

// 対象ディレクトリ内のすべてのPHPファイルを取得
$files = scanDirectory($directory);

/**
 * 色付け。赤限定
 * @param  string $text
 * @return string
 */
function colorText(string $text): string
{
    global $options;
    if(isset($options['no-color'])){
        return $text;
    }
    return "\033[31m" . $text . "\033[0m";
}

/**
 * 文字単位で差分を計算し、強調する関数。
 *
 * @param  string $before 修正前の文字列
 * @param  string $after  修正後の文字列
 * @return string 差分部分を色付きで強調表示した文字列
 */
function highlightCharacterDiff(string $before, string $after): string
{
    $diffStart     = 0;
    $diffEndBefore = strlen($before);
    $diffEndAfter  = strlen($after);

    // 差分開始位置を見つける
    while ($diffStart < $diffEndBefore && $diffStart < $diffEndAfter && $before[$diffStart] === $after[$diffStart]) {
        ++$diffStart;
    }

    // 差分終了位置を見つける
    while (
        $diffEndBefore > $diffStart
        && $diffEndAfter > $diffStart
        && $before[$diffEndBefore - 1] === $after[$diffEndAfter - 1]
    ) {
        --$diffEndBefore;
        --$diffEndAfter;
    }

    // 差分を色付け
    $afterHighlighted = substr($after, 0, $diffStart)
        . colorText(substr($after, $diffStart, $diffEndAfter - $diffStart))
        . substr($after, $diffEndAfter);

    // 結果を整形して返す
    return "$before\n↓\n$afterHighlighted";
}

// 出力処理
$mod = false;
foreach ($files as $file) {
    $code   = file_get_contents($file);
    $result = findAndFixInvalidNullDefaults($code);

    if ($result === null) {
        continue;
    }
    $mod = true;
    [$fixedCode, $fixes, $warnings] = $result;
    // ディレクトリ名と同じ文字列があると str_replace では余分に消えるので先頭から文字を削る
    $relativePath = substr($file, strlen($directory));

    echo str_repeat('=', 80) . "\n";
    echo "ファイル: $relativePath\n";

    foreach ($fixes as $fix) {
        echo "  [修正] 行 {$fix['line']}:\n";
        echo highlightCharacterDiff($fix['before'], $fix['after']) . "\n\n";
    }

    foreach ($warnings as $warning) {
        echo "  [警告] 行 {$warning['line']}:" . "\n";
        echo "    原因: {$warning['reason']}\n";
        echo "    対象コード:\n{$warning['code']}\n";
        echo "\n";
    }

    if (! isset($options['dry-run']) && ! empty($fixes)) {
        file_put_contents($file, $fixedCode);
    }
}
if(!$mod){
    echo "修正対象が見つかりませんでした。\n";
}

 ざっくりいうとPHPのソースコードを言語構造にし、引数のデフォルト値が null の場合に型をチェックし、型に問題があれば修正 or 警告を出すスクリプトです。これを実行すると次のようになります。

$ php toNullable.php --directory=tmp/ --dry-run
================================================================================
ファイル: nullable_sample.php
  [修正] 行 4:
function hasNullableArgStr(string $a = null): void {
    echo $a;
}
↓
function hasNullableArgStr(?string $a = null): void {
    echo $a;
}

 –dry-run では修正せずに修正内容のみを出力し、–dry-run なしでは自動修正をします。ざっくり実行して全て自動修正してもいいし、個別に妥当な変更を検討するのもよしです。交差型についてはいい感じの解決方法が思いつかなかったのでメッセージを表示するのみにしています。解決するのであれば交差型相当のインターフェースかクラスを作り、それと null のユニオン型にするのがよさそうです。

 nikic/php-parser はPHPのソースコードと構文木を簡単に行き来できるライブラリです。これを使うと上記例のようにPHPのコードの改修を自動化できます。特筆すべきところはPHPの言語構造を使っているところです。これを用いるとは正規表現や文字列置換では困難な作業も比較的楽にできるようになり便利です。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG