🐶

さわって慣れるPHP Parser - NameResolver

2023/07/07に公開

さわって慣れるPHP Parser - IdentifierとNameとFullyQualifiedの続編です。

PHP ParserはNameResolverという仕組みを持っています。今回はNameResolverに注目してみます。ドキュメントによると関数、クラス、インターフェース、トレイト、定数の宣言を表すノードにはnamespacedNameプロパティがあり、名前空間付きのクラス名等がNameResolverにより設定されるそうです。

実際に動かして挙動を観察してみたいと思います。

-Nまたは--resolve-namesオプションによりNameResolverを利用することができると分かります。

$ vendor/bin/php-parse --help
Usage: php-parse [operations] file1.php [file2.php ...]
   or: php-parse [operations] "<?php code"
Turn PHP source code into an abstract syntax tree.

Operations is a list of the following options (--dump by default):

    -d, --dump              Dump nodes using NodeDumper
    -p, --pretty-print      Pretty print file using PrettyPrinter\Standard
    -j, --json-dump         Print json_encode() result
        --var-dump          var_dump() nodes (for exact structure)
    -N, --resolve-names     Resolve names using NodeVisitor\NameResolver
    -c, --with-column-info  Show column-numbers for errors (if available)
    -P, --with-positions    Show positions in node dumps
    -r, --with-recovery     Use parsing with error recovery
    -h, --help              Display this page

ドキュメントにも使い方が書かれていますが、プロダクションコードの生きた事例を見てみたいのでphp-parse[1]を覗いてみます。処理の冒頭でTraverserを準備しています。NameResolverはVisitor[2]のようです。

$traverser = new PhpParser\NodeTraverser();
$traverser->addVisitor(new PhpParser\NodeVisitor\NameResolver);

文脈を省略しますが、--resolve-namesを指定していると$stmts = $traverser->traverse($stmts);が実行されると分かります。

    foreach ($operations as $operation) {
        if ('dump' === $operation) {
            fwrite(STDERR, "==> Node dump:\n");
            echo $dumper->dump($stmts, $code), "\n";
        } elseif ('pretty-print' === $operation) {
            fwrite(STDERR, "==> Pretty print:\n");
            echo $prettyPrinter->prettyPrintFile($stmts), "\n";
        } elseif ('json-dump' === $operation) {
            fwrite(STDERR, "==> JSON dump:\n");
            echo json_encode($stmts, JSON_PRETTY_PRINT), "\n";
        } elseif ('var-dump' === $operation) {
            fwrite(STDERR, "==> var_dump():\n");
            var_dump($stmts);
        } elseif ('resolve-names' === $operation) {
            fwrite(STDERR, "==> Resolved names.\n");
            $stmts = $traverser->traverse($stmts);
        }
    }

$stmtstraverse()して$stmtsに入れ直しており、$stmtsを何等か加工してそうです。

では観察してみます。

解析対象のコード(Asia.php)をしたためます。(PHPとしての適切さは度外視し、ノードを観察するためだけのコードです。)

<?php
namespace Red;
class Stone{}

解析します。

$ vendor/bin/php-parse -N --var-dump Asia.php 
====> File Asia.php:
==> Resolved names.
==> var_dump():
array(1) {
  [0]=>
  object(PhpParser\Node\Stmt\Namespace_)#1202 (3) {
    ["name"]=>
    object(PhpParser\Node\Name)#1201 (2) {
      ["parts"]=>
      array(1) {
        [0]=>
        string(3) "Red"
      }
      ["attributes":protected]=>
      array(4) {
        ["startLine"]=>
        int(2)
        ["startFilePos"]=>
        int(16)
        ["endLine"]=>
        int(2)
        ["endFilePos"]=>
        int(18)
      }
    }
    ["stmts"]=>
    array(1) {
      [0]=>
      object(PhpParser\Node\Stmt\Class_)#1204 (8) {
        ["flags"]=>
        int(0)
        ["extends"]=>
        NULL
        ["implements"]=>
        array(0) {
        }
        ["name"]=>
        object(PhpParser\Node\Identifier)#1203 (2) {
          ["name"]=>
          string(5) "Stone"
          ["attributes":protected]=>
          array(4) {
            ["startLine"]=>
            int(3)
            ["startFilePos"]=>
            int(27)
            ["endLine"]=>
            int(3)
            ["endFilePos"]=>
            int(31)
          }
        }
        ["stmts"]=>
        array(0) {
        }
        ["attrGroups"]=>
        array(0) {
        }
        ["namespacedName"]=>
        object(PhpParser\Node\Name)#1205 (2) {
          ["parts"]=>
          array(2) {
            [0]=>
            string(3) "Red"
            [1]=>
            string(5) "Stone"
          }
          ["attributes":protected]=>
          array(0) {
          }
        }
        ["attributes":protected]=>
        array(4) {
          ["startLine"]=>
          int(3)
          ["startFilePos"]=>
          int(21)
          ["endLine"]=>
          int(3)
          ["endFilePos"]=>
          int(33)
        }
      }
    }
    ["attributes":protected]=>
    array(5) {
      ["startLine"]=>
      int(2)
      ["startFilePos"]=>
      int(6)
      ["endLine"]=>
      int(3)
      ["endFilePos"]=>
      int(33)
      ["kind"]=>
      int(1)
    }
  }
}

色々でてますが読み流してnamespacedNameプロパティを見つけました。解析したのはClass_ノードですが、これが継承するClassLikenamespacedNameプロパティを持っています。ちなみに、Enum_, Interface_, Trait_ClassLikeを継承しています。
https://apiref.phpstan.org/1.9.x/PhpParser.Node.Stmt.ClassLike.html

        ["namespacedName"]=>
        object(PhpParser\Node\Name)#1205 (2) {
          ["parts"]=>
          array(2) {
            [0]=>
            string(3) "Red"
            [1]=>
            string(5) "Stone"
          }

クラスの修飾名が格納されていることがわかりました。

もう一つ試してみます。

<?php
use Super\Asia;
new Asia();

解析結果を抜粋します。

  [1]=>
  object(PhpParser\Node\Stmt\Expression)#1206 (2) {
    ["expr"]=>
    object(PhpParser\Node\Expr\New_)#1205 (3) {
      ["class"]=>
      object(PhpParser\Node\Name\FullyQualified)#1208 (2) {
        ["parts"]=>
        array(2) {
          [0]=>
          string(5) "Super"
          [1]=>
          string(4) "Asia"
        }

クラスの完全修飾名が格納されていることがわかりました。

ところでPHP Parserのコードベースを$namespacedNameで検索してみます。3つヒットしました。
Const_
ClassLike
Function_

ClassLikeは抽象クラスで、具象クラスは以下の4つです。
Class_
Enum_
Interface_
Trait_

まとめると、定数、関数、クラス、列挙型、インターフェース、トレイトにはNameResolverが解決した(完全)修飾名を格納できることがわかりました。これらは名前空間付きで識別される者たちであると受け取りました。PHPの仕様に若干詳しくなれた気がします。

ただドキュメントにEnumが明記されていなかったのが気になります。観察してみましょう。

<?php
namespace Cars;
enum MenOfPillar
{
    case Acdc;
    case Wham;
    case Santana;
}

解析結果を抜粋します。namespacedNameがありました。

        ["namespacedName"]=>
        object(PhpParser\Node\Name)#1211 (2) {
          ["parts"]=>
          array(2) {
            [0]=>
            string(4) "Cars"
            [1]=>
            string(11) "MenOfPillar"
          }
          ["attributes":protected]=>
          array(0) {
          }
        }

ドキュメントによるとNameResolverの引数にオプションを渡せるようです。解析結果を観察したくなりますがひとまず我慢します。解析結果を格納する入れ物には以下4つのパターンがあるということとざっくり理解しました。

  • namespacedName property(上述)
  • originalName attribute
  • resolvedName attribute
  • namespacedName attribute

ひとつめはproperty、残る3つはattributeです。attributeというのは言語仕様のアトリビュートではなくノードに付随するPHP Parserが与える情報です。実は上述の解析結果の中にもありました。

さて、上述した通り、NameResolverはVisitorということでした。これはenterNode()[3]を実装しています。雑に読んでみます。

    public function enterNode(Node $node) {
        if ($node instanceof Stmt\Namespace_) {
            $this->nameContext->startNamespace($node->name);
        } elseif ($node instanceof Stmt\Use_) {
            foreach ($node->uses as $use) {
                $this->addAlias($use, $node->type, null);
            }
        } elseif ($node instanceof Stmt\GroupUse) {
            foreach ($node->uses as $use) {
                $this->addAlias($use, $node->type, $node->prefix);
            }
        } elseif ($node instanceof Stmt\Class_) {
            if (null !== $node->extends) {
                $node->extends = $this->resolveClassName($node->extends);
            }

            foreach ($node->implements as &$interface) {
                $interface = $this->resolveClassName($interface);
            }

            $this->resolveAttrGroups($node);
            if (null !== $node->name) {
                $this->addNamespacedName($node);
            }
        } elseif ($node instanceof Stmt\Interface_) {
            foreach ($node->extends as &$interface) {
                $interface = $this->resolveClassName($interface);
            }

            $this->resolveAttrGroups($node);
            $this->addNamespacedName($node);
         } elseif ($node instanceof Stmt\Enum_) {
            foreach ($node->implements as &$interface) {
                $interface = $this->resolveClassName($interface);
            }

            $this->resolveAttrGroups($node);
            if (null !== $node->name) {
                $this->addNamespacedName($node);
            }
        } elseif ($node instanceof Stmt\Trait_) {
            $this->resolveAttrGroups($node);
            $this->addNamespacedName($node);
        } elseif ($node instanceof Stmt\Function_) {
            $this->resolveSignature($node);
            $this->resolveAttrGroups($node);
            $this->addNamespacedName($node);
        } elseif ($node instanceof Stmt\ClassMethod
                  || $node instanceof Expr\Closure
                  || $node instanceof Expr\ArrowFunction
        ) {
            $this->resolveSignature($node);
            $this->resolveAttrGroups($node);
        } elseif ($node instanceof Stmt\Property) {
            if (null !== $node->type) {
                $node->type = $this->resolveType($node->type);
            }
            $this->resolveAttrGroups($node);
        } elseif ($node instanceof Stmt\Const_) {
            foreach ($node->consts as $const) {
                $this->addNamespacedName($const);
            }
        } else if ($node instanceof Stmt\ClassConst) {
            $this->resolveAttrGroups($node);
        } else if ($node instanceof Stmt\EnumCase) {
            $this->resolveAttrGroups($node);
        } elseif ($node instanceof Expr\StaticCall
                  || $node instanceof Expr\StaticPropertyFetch
                  || $node instanceof Expr\ClassConstFetch
                  || $node instanceof Expr\New_
                  || $node instanceof Expr\Instanceof_
        ) {
            if ($node->class instanceof Name) {
                $node->class = $this->resolveClassName($node->class);
            }
        } elseif ($node instanceof Stmt\Catch_) {
            foreach ($node->types as &$type) {
                $type = $this->resolveClassName($type);
            }
        } elseif ($node instanceof Expr\FuncCall) {
            if ($node->name instanceof Name) {
                $node->name = $this->resolveName($node->name, Stmt\Use_::TYPE_FUNCTION);
            }
        } elseif ($node instanceof Expr\ConstFetch) {
            $node->name = $this->resolveName($node->name, Stmt\Use_::TYPE_CONSTANT);
        } elseif ($node instanceof Stmt\TraitUse) {
            foreach ($node->traits as &$trait) {
                $trait = $this->resolveClassName($trait);
            }

            foreach ($node->adaptations as $adaptation) {
                if (null !== $adaptation->trait) {
                    $adaptation->trait = $this->resolveClassName($adaptation->trait);
                }

                if ($adaptation instanceof Stmt\TraitUseAdaptation\Precedence) {
                    foreach ($adaptation->insteadof as &$insteadof) {
                        $insteadof = $this->resolveClassName($insteadof);
                    }
                }
            }
        }

        return null;
    }

雑に読みます。

  • ノードの種類によって処理が分かれている。
  • 上3つ(Namespace_, Use_, GroupUse)は名前空間を特定する部分っぽくて、そこから下のノードはそれを受け取りpropertyやattributeに設定する側っぽい。
  • Class_, Interface_, Enum_, Trait_, Function_はちょっとした差異がありつつ雰囲気が似てて、$this->resolveAttrGroups($node)$this->addNamespacedName($node)でごにょごにょしてる。
  • addNamespacedName()でnamespacedName propertyを設定してる。
  • ClassMethod, Closure, ArrowFunctionには$this->addNamespacedName($node)がない。ちなみに、Function_, ClassMethod, Closure, ArrowFunctionは全てFunctionLikeを継承する仲間。なんだけど名前空間の扱いについてはFunction_と差異がある。性質の違いがこういうところに表れてる。
  • (この時点で$namespacedNameを有する6つのうちConst_だけがまだ登場してないなあ)
  • Propertyには$this->resolveAttrGroups($node)だけがある。
  • Const_キタ。$this->resolveAttrGroups($node)がない。アトリビュートは定数に対して付加されないと。
  • ClassConstEnumCaseには$this->resolveAttrGroups($node)だけがある。
  • StaticCall以下はざっくりとNameノードを内包するノードっぽいけど全部ではなさそう。

なるほど。

最後にNameノードを内包するノードの分析結果を観察してみたいと思います。

ただその前にノードに関するTipsに触れてみます。
関数には宣言と使用(呼び出し)の2つの側面があり、ノードもそれぞれ異なるという風に私は捉えています。前者はFunction_、後者はFuncCallといった具合です。FuncCallには関数名を表す$nameというプロパティがありこれはNameノード(またはExprノードなんですが発散するので割愛します)です。
クラスも同様に私は捉えています。宣言はClass_、それ以外は例えばStaticCallにおいてクラス名を表す$classプロパティ(これもNameまたはExprノード)や、引数や戻り値の型を表すノードにおいてNameとして現れたりします。
このように宣言と、それを使用する部分を混同しないように注意しています。

話を戻します。

NameResolverが解析結果を格納する入れ物が4つあるということでした。その内の1つは、宣言のノードたちが持つnamespacedName propertyでした。一方、Nameノードは宣言のノードではなくそれを持ちません。ですがNameResolverのpreserveOriginalNamesオプションをtrueにしてやるとoriginalName attributeに値を設定するようになるようです。

ではやってみます。php-parseを細工します。

- $traverser->addVisitor(new PhpParser\NodeVisitor\NameResolver);
+ $traverser->addVisitor(new PhpParser\NodeVisitor\NameResolver(null, ['preserveOriginalNames' => true]));

StaticCallのケースを試してみます。

<?php
use Tibet\Dire;
Dire::introduceMyself();

解析結果を抜粋します。

    object(PhpParser\Node\Expr\StaticCall)#1206 (4) {
      ["class"]=>
      object(PhpParser\Node\Name\FullyQualified)#1209 (2) {
        ["parts"]=>
        array(2) {
          [0]=>
          string(5) "Tibet"
          [1]=>
          string(4) "Dire"
        }
        ["attributes":protected]=>
        array(5) {
          ["startLine"]=>
          int(3)
          ["startFilePos"]=>
          int(22)
          ["endLine"]=>
          int(3)
          ["endFilePos"]=>
          int(25)
          ["originalName"]=>
          object(PhpParser\Node\Name)#1204 (2) {
            ["parts"]=>
            array(1) {
              [0]=>
              string(4) "Dire"
            }

Direクラスが完全修飾名(FullyQualified)で解決されており、FullyQualifiedattributesoriginalNameがいました。originalNameには名前解決されてないクラス名が格納されていることを確認できました。

脚注
  1. PHP Parser v4.16.0で確認。 ↩︎

  2. Rectorと目指す 負債をためないシステム開発~はじめの一歩~(P59~70) ↩︎

  3. Walking the AST ↩︎

Discussion