PHPを検査するPHPを書く / Write PHP inspection by PHP

PHPを検査するPHPを書く / Write PHP inspection by PHP

PHPカンファレンス2018 Track7

F5a0754ff7f5cb7b4c30416e3b7ad1f5?s=128

Kazuma Watanabe

December 15, 2018
Tweet

Transcript

  1. PHPを検査するPHPを書く PHPカンファレンス2018 @wata727

  2. 自己紹介 • @wata727 • Sider, Inc. • GitHub: @wata727 •

    Twitter: @wata727_
  3. 宣伝

  4. None
  5. Sider • GitHubと連携するコードレビュー支援SaaS • プルリクエストを解析し、結果をGitHubに報告 • ベストプラクティスやプロジェクト特有のルールに 関するレビューを自動化して、繰り返しを防ぐ

  6. 本題

  7. PHPを検査するとはなにか

  8. いろいろありますが... 今回は主に 「特定のよくある間違いを見つけること」 に焦点を当てます

  9. 例えばこんなコード in_array()の第三引数を省略すると「緩やかな比較」 になる(デフォルト値がfalse) in_array("string", [0, 1]); // => true in_array("string",

    [0, 1], true); // => false
  10. 例えばこんなコード PHP 5.4から配列をarray()以外で宣言可能に $a = array(1, 2); $b = [1,

    2]; // PHP 5.4以降
  11. 例えばこんなコード arrayのキーの重複(重複した場合、後から宣言され た要素によって上書きされる) $a = [ "201403" => 0.5, "201404"

    => 0.5, "201404" => 0.8, // "201404"が2つある! "201405" => 0,8 ]; "201404" "201404"
  12. 今回はこれらを 機械的にチェックする方法 について話します

  13. 大体の問題は解決されている • in_array()の第三引数 ◦ https://github.com/phpstan/phpstan-strict-rules • arrayの短縮構文 ◦ Generic.Arrays.DisallowLongArraySyntaxSniff •

    arrayのキー重複 ◦ Phan, PHPStan, Pahoutなど
  14. ではなぜやるのか? • 意外と簡単 • PHPそのものに詳しくなれる • 既存のLinterに機能追加やバグ修正を送れる • オリジナルのLinterを作れる •

    楽しい!
  15. ソースコードの解析ステップ

  16. ソースコードの解析ステップ 1. 字句解析 2. 構文解析 3. 抽象構文木の走査 4. 結果の出力

  17. 字句解析 token_get_all('<?php echo; ?>'); [ T_OPEN_TAG, T_ECHO, T_WHITESPACE, T_CLOSE_TAG ]

    単なる文字列である ソースコードから 「トークン列」を生成する
  18. 構文解析 \ast\parse_code( '<?php echo "Hello, World!"; ?>', 60 ); AST_STMT_LIST

    0: AST_ECHO expr: "Hello, World!" 得られたトークン列から 抽象構文木(AST)を得る
  19. ちょっとまって

  20. なんでこんなん必要なの?

  21. 素朴な疑問 • 単なる文字列パターンの認識なら正規表現でい いんじゃないの? • トークン列への変換とか、構文解析とかやる必要 なくない?

  22. 正規表現は難しい var_dump()があるかどうかチェックする $code = file_get_contents("input.php"); if (preg_match("/var_dump/", $code)) { echo

    "Found!"; }
  23. 正規表現は難しい コメントを区別できない // var_dump($obj) $obj = somefunc();

  24. 正規表現は難しい コメントを区別できない // var_dump($obj) $obj = somefunc(); preg_match(/^[^(\/\/)]+var_dump/, $code);

  25. 正規表現は難しい 文字列リテラルを区別できない <?php 'var_dump is debug code';

  26. 正規表現は難しい 文字列リテラルを区別できない preg_match(/^[^(\/\/|'|\")]+var_dump/, $code); <?php 'var_dump is debug code';

  27. 正規表現は難しい 類似した関数名を区別できない $dumper = var_dumper(); $dumper->dump();

  28. 正規表現は難しい 類似した関数名を区別できない preg_match(/^[^(\/\/|'|\")]+var_dump\(/,$code); $dumper = var_dumper(); $dumper->dump();

  29. 無理

  30. 字句解析器を使う 字句解析器は先に書いた例を区別できる var_dump($obj); // var_dump($obj); var_dumper(); T_STRING (var_dump) T_COMMENT T_STRING

    (var_dumper)
  31. 意味に無関係なトークンを消す 字句解析で得られたトークンには「実行結果に影響 を与えないトークン」が含まれる var_dump($obj); var_dump( $obj ); T_STRING (var_dump) T_VARIABLE

    ($obj) T_STRING (var_dump) T_WHITESPACE T_VARIABLE ($obj) T_WHITESPACE
  32. 構文解析器を使う 構文解析で得られるASTはこれらを区別しない var_dump($obj); var_dump( $obj ); AST_STMT_LIST 0: AST_CALL expr:

    AST_NAME flags: NAME_NOT_FQ (1) name: "var_dump" args: AST_ARG_LIST 0: AST_VAR name: "obj"
  33. AST?

  34. 抽象構文木 (Abstract Syntax Tree) • 意味の変わらない不要なトークンを落とし、ソース コードを木構造で表現したもの

  35. 抽象構文木の例 AST_STMT_LIST 0: AST_CALL expr: AST_NAME flags: NAME_NOT_FQ (1) name:

    "in_array" args: AST_ARG_LIST 0: "string" 1: AST_ARRAY flags: ARRAY_SYNTAX_SHORT (3) 0: AST_ARRAY_ELEM flags: 0 value: 0 key: null 1: AST_ARRAY_ELEM flags: 0 value: 1 key: null 2: AST_CONST name: AST_NAME flags: NAME_NOT_FQ (1) name: "true" in_array( "string", [0, 1], true );
  36. 抽象構文木の例 AST_CALL AST_NAME AST_ARG_LIST “string” AST_ARRAY AST_CONST in_array [0, 1]

    true
  37. 抽象構文木の例 AST_CALL AST_NAME AST_ARG_LIST “string” AST_ARRAY AST_CONST in_array [0, 1]

    true in_array("string", [0, 1], true);
  38. 抽象構文木の例 AST_CALL AST_NAME AST_ARG_LIST “string” AST_ARRAY AST_CONST in_array [0, 1]

    true in_array("string", [0, 1], true);
  39. 抽象構文木の例 AST_CALL AST_NAME AST_ARG_LIST “string” AST_ARRAY AST_CONST in_array [0, 1]

    true in_array("string", [0, 1], true);
  40. PHPの有名な構文解析器 • nikic/PHP-Parser ◦ たぶん一番有名、全部PHPで書かれている ◦ PHPStanなどで採用 • nikic/php-ast ◦

    内部で生成されたASTを取得するextension(早い) ◦ Phanなどで採用
  41. PHPの有名な構文解析器 • nikic/PHP-Parser ◦ たぶん一番有名、全部PHPで書かれている ◦ PHPStanなどで採用 • nikic/php-ast ◦

    内部で生成されたASTを取得するextension(早い) ◦ Phanなどで採用 今日はこっちを話します
  42. php-astが生成するASTの例 object(ast\Node)#1 (4) { ["kind"]=> int(132) ["flags"]=> int(0) ["lineno"]=> int(1)

    ["children"]=> array(1) { [0]=> object(ast\Node)#2 (4) { … } } } in_array( "string", [0, 1], true );
  43. 抽象構文木を走査する

  44. どうやってASTを検査するのか • 得られたASTは単なるast\Nodeインスタンス • Nodeがさらに子ノードを持つ • 適当にforeachして、中身のNodeの種類に応じて if文を書けばいいんじゃない?

  45. foreach ($root->children as $node) { if ($node instanceof Node) {

    if ($node->kind === AST_CALL && $node->children['expr']->kind === AST_NAME && $node->children['expr']->children['name'] === "in_array") { echo "Found!"; } } } in_array()を愚直に探す
  46. foreach ($root->children as $node) { if ($node instanceof Node) {

    if ($node->kind === AST_CALL && $node->children['expr']->kind === AST_NAME && $node->children['expr']->children['name'] === "in_array") { echo "Found!"; } } } in_array()を愚直に探す AST_STMT_LIST 0: AST_CALL expr: AST_NAME flags: NAME_NOT_FQ (1) name: "in_array" args: AST_ARG_LIST ...
  47. foreach ($root->children as $node) { if ($node instanceof Node) {

    if ($node->kind === AST_CALL && $node->children['expr']->kind === AST_NAME && $node->children['expr']->children['name'] === "in_array") { echo "Found!"; } } } in_array()を愚直に探す AST_STMT_LIST 0: AST_CALL expr: AST_NAME flags: NAME_NOT_FQ (1) name: "in_array" args: AST_ARG_LIST ...
  48. foreach ($root->children as $node) { if ($node instanceof Node) {

    if ($node->kind === AST_CALL && $node->children['expr']->kind === AST_NAME && $node->children['expr']->children['name'] === "in_array") { echo "Found!"; } } } in_array()を愚直に探す AST_STMT_LIST 0: AST_CALL expr: AST_NAME flags: NAME_NOT_FQ (1) name: "in_array" args: AST_ARG_LIST ...
  49. if文の中にあると検出できない AST_STMT_LIST 0: AST_IF 0: AST_IF_ELEM cond: AST_CALL expr: ...

    args: ... stmts: AST_STMT_LIST 0: AST_CALL expr: AST_NAME flags: NAME_NOT_FQ (1) name: "in_array" args: ... if (condition()) { in_array( "string", [0, 1], true ); } in_array( "string", [0, 1], true ); 0: AST_CALL expr: AST_NAME flags: NAME_NOT_FQ (1) name: "in_array" args: ... 部分的な構造は同じ
  50. foreach ($root->children as $node) { if ($node instanceof Node) {

    if ($node->kind === AST_CALL && $node->children['expr']->kind === AST_NAME && $node->children['expr']->children['name'] === "in_array") { echo "Found!"; } if ($node->kind === AST_IF) { foreach ($node->children as $if_elem) { ... } } } } if文を考慮する if ($node->kind === AST_IF) { foreach ($node->children as $if_elem) { ... } }
  51. foreach ($root->children as $node) { if ($node instanceof Node) {

    if ($node->kind === AST_CALL && $node->children['expr']->kind === AST_NAME && $node->children['expr']->children['name'] === "in_array") { echo "Found!"; } if ($node->kind === AST_IF) { foreach ($node->children as $if_elem) { ... } } } } if文を考慮する if ($node->kind === AST_IF) { foreach ($node->children as $if_elem) { ... } } AST_STMT_LIST 0: AST_IF 0: AST_IF_ELEM cond: AST_CALL expr: ... args: ... stmts: AST_STMT_LIST 0: AST_CALL expr: AST_NAME flags: NAME_NOT_FQ (1) name: "in_array" args: ...
  52. foreach ($root->children as $node) { if ($node instanceof Node) {

    if ($node->kind === AST_CALL && $node->children['expr']->kind === AST_NAME && $node->children['expr']->children['name'] === "in_array") { echo "Found!"; } if ($node->kind === AST_IF) { foreach ($node->children as $if_elem) { ... } } } } if文を考慮する if ($node->kind === AST_IF) { foreach ($node->children as $if_elem) { ... } } AST_STMT_LIST 0: AST_IF 0: AST_IF_ELEM cond: AST_CALL expr: ... args: ... stmts: AST_STMT_LIST 0: AST_CALL expr: AST_NAME flags: NAME_NOT_FQ (1) name: "in_array" args: ...
  53. 他のケースは? • 例えば elseの中にin_array()があったら? • 関数の引数にin_array()があったら? • try...catchの中にin_array()があったら? • などなど...

  54. 無理

  55. どうすればいいのか • in_array()の含まれるASTの構造は様々 • 構造に関係なく、AST_CALLのNodeが来たとき に検査したい

  56. Visitorクラスの導入 • データ構造を走査するためのクラスと、ある特定 の要素を処理するクラスを分離する • Nodeを検査するロジックを実装したクラスを用意 する(Visitor) • 子ノードなどを走査して、Nodeが来るたびに Visitorを適用する(Traverser)

  57. Visitorの実装 class Visitor { public function enterNode(Node $node) { if

    ($node->kind === AST_CALL) { $this->processCall($node); } } }
  58. Visitorの実装 class Visitor { public function enterNode(Node $node) { if

    ($node->kind === AST_CALL) { $this->processCall($node); } } } 単一のNodeのみを考慮する (子ノードなどの構造は考慮しなくて良い)
  59. Traverserの実装 class Traverser { public function traverse(Node $node) { $this->visitor->enterNode($node);

    foreach ($node->children as $child) { if ($child instanceof Node) { $this->traverse($child); } } } }
  60. Traverserの実装 class Traverser { public function traverse(Node $node) { $this->visitor->enterNode($node);

    foreach ($node->children as $child) { if ($child instanceof Node) { $this->traverse($child); } } } } Nodeが来るたびにVisitorを呼び出す
  61. Traverserの実装 class Traverser { public function traverse(Node $node) { $this->visitor->enterNode($node);

    foreach ($node->children as $child) { if ($child instanceof Node) { $this->traverse($child); } } } } 子Nodeがある場合、さらに走査する 再帰で実装できる
  62. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  63. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  64. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  65. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  66. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  67. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  68. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  69. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  70. 深さ優先探索のイメージ AST_IF AST_IF_ELEM AST_CALL AST_STMT_LIST AST_CALL

  71. 実装例 • https://github.com/wata727/pahout • 処理の流れ ◦ bin/pahout ◦ src/Command/Check.php ◦

    src/Pahout.php ◦ src/Formatter.php
  72. Thank you! https://sider.review/