1
2

php-astを利用しソースコードを安全にフォーマットする

Posted at

はじめに

「ソースコード全てフォーマットしたい」
「でも、テストケースが少ないからフォーマットしてデグレしてないか分からない」
「工数も限られているからできるだけ楽にチェックしたい」
これらはphp-astを利用すれば可能です。
ただし、長期間にわたり運用されているプロダクトでは、容易に対処できない問題もあります。私が試した結果うまくいかなかったことと、それを解決する方法について共有したいと思います。

注意
掲載されている内容や情報を利用することにより、直接または間接的に生じたいかなる問題、トラブル、損害に対しても、一切の責任を負いません。ご利用は自己責任でお願いいたします。

背景

php-cs-fixerを導入しましたが、フォーマットされた変更が修正と混ざり、レビューが難しくなってしまいました。そのため、LGTMをもらった後に修正ファイルをフォーマットして、再度レビューを依頼する運用を採用していました。しかし、この方法ではフォーマッタの真価を生かせていないと感じ、どうにか改善できないか模索していました。
最初にすべてのファイルを一斉にフォーマットするアプローチも考えましたが、自動テストのカバレッジが低く、変更されたファイル数が多いため、「フォーマット後に問題がないか」を確認することが難しい状況でした。

ソースコード

実際のコードを動かしながらいろいろ試したい方は以下から確認可能です
https://github.com/soshi-ikegami/php-ast-check

方法

後述している参考記事にもありますがphp-astというライブラリを利用します

  1. php-astでastを生成
  2. astをmd5でハッシュ化しハッシュ値を算出
  3. フォーマット前後のハッシュ値を比較し違いがないことを確認

うまくいかなかったこと & 解決方法

フォーマット前後で挙動は同じでもastが異なることがあります
そのような挙動に影響しないと分かっているものはハッシュ値を算出する前に置換してやる必要があります。具体的には深さ優先探索で子要素を探索していき、条件に合うものを置換します

array()と[]でハッシュ値が異なる

<?php
- $a = array();
+ $a = [];
// ↓ ast_dump
AST_STMT_LIST
    0: AST_ASSIGN
        var: AST_VAR
            name: "a"
        expr: AST_ARRAY
-            flags: ARRAY_SYNTAX_SHORT (3)
+            flags: ARRAY_SYNTAX_LONG (2)

array_syntaxルールによりarray()[]に置換されます
それによりflagsという要素が変わるためハッシュ値も変わってしまいます

解決方法

function formatArray(Node $child) {
    // AST_ARRAY: 129 配列
    // ARRAY_SYNTAX_LONG: 2 []
    // ARRAY_SYNTAX_SHORT: 3 array()
    if ($child->kind === 129) {
        if ($child->flags === 3) {
            $child->flags = 2;
        }
    }
}

子要素のkindが129でflagsが3であれば2に置き換えて対応しました
それにしても、array()[]だとarray()のほうがARRAY_SYNTAX_LONGって感じがしますがARRAY_SYNTAX_SHORTなんですね。長いのに短いって変な感じです

phpdocのスペースによってハッシュ値が異なる

<?php
/**
- *  test
+ * test
 */
function test()
{
}
// ↓ ast_dump
AST_STMT_LIST
    0: AST_FUNC_DECL
        name: "test"
        docComment: "/**
-         *  test
+         * test
         */"
        params: AST_PARAM_LIST
        stmts: AST_STMT_LIST
        returnType: null
        __declId: 0

testの一つ前にスペースがありましたがphpdoc_alignルールにより削除されました
それによりdocCommentという要素の内容が変わるためハッシュ値も変わってしまいます

解決方法

function formatDoc(Node $child) {
    // AST_FUNC_DECL: 67 関数のDoc
    // AST_METHOD: 69 メソッドのDoc
    // AST_CLASS: 70 クラスのDoc
    // AST_CONST_DECL: 139 定数のDoc
    // AST_CLASS_CONST_DECL: 140 クラス定数のDoc
    // AST_PROP_ELEM: 775 プロパティ
    if (in_array($child->kind, [67, 69, 70, 139, 140, 775], true)) {
        if (isset($child->children["docComment"])) {
            $child->children["docComment"] = preg_replace('/ |\s+/', '', $child->children["docComment"]);
            if (preg_match('/\*{3,}/', $child->children["docComment"])) {
                $child->children["docComment"] = null;
            }
        }
    }
}

子要素のkindが67, 69, 70, 139, 140, 775のいずれかでdocCommentという要素があれば空白文字を全て削除して対応しました。また、中身のないphpdocがあるとno_empty_phpdocルールにより削除されてしまうのでnullをセットしました

定数が大文字と小文字でハッシュ値が異なる

<?php
- $a = FALSE;
+ $a = false;
// ↓ ast_dump
AST_STMT_LIST
    0: AST_ASSIGN
        var: AST_VAR
            name: "a"
        expr: AST_CONST
            name: AST_NAME
                flags: NAME_NOT_FQ (1)
-                name: "FALSE"
+                name: "false"

falseやtrue、nullといった定数はFALSEやTRUEと書くことができます
これらはフォーマットするとlowercaseに置換されるためハッシュ値が変わってしまいます

対応方法

function formatConst(Node $child) {
    // AST_NAME: 2048 名前
    if ($child->kind === 2048) {
        if (in_array(strtolower($child->children['name']), ["null", "true", "false"], true)) {
            $child->children['name'] = strtolower($child->children['name']);
        }
    }
}

name要素が小文字でfalse,true,nullのいずれかであればlowercaseにして置き換えて対応しました。

else ifelseif でハッシュ値が異なる

<?php

if (true) {
    $a = 1;
- } else if (true) {
+ } elseif (true) {
    $a = 2;
}
// ↓ ast_dump
AST_STMT_LIST
    0: AST_IF
        0: AST_IF_ELEM
            cond: AST_CONST
                name: AST_NAME
                    flags: NAME_NOT_FQ (1)
                    name: "true"
            stmts: AST_STMT_LIST
                0: AST_ASSIGN
                    var: AST_VAR
                        name: "a"
                    expr: 1
        1: AST_IF_ELEM
-            cond: null
-            stmts: AST_STMT_LIST
-                0: AST_IF
-                    0: AST_IF_ELEM
-                        cond: AST_CONST
-                            name: AST_NAME
-                                flags: NAME_NOT_FQ (1)
-                                name: "true"
-                        stmts: AST_STMT_LIST
-                            0: AST_ASSIGN
-                                var: AST_VAR
-                                    name: "a"
-                                expr: 2
+            cond: AST_CONST
+                name: AST_NAME
+                    flags: NAME_NOT_FQ (1)
+                    name: "true"
+            stmts: AST_STMT_LIST
+                0: AST_ASSIGN
+                    var: AST_VAR
+                        name: "a"
+                    expr: 2

elseifルールによりelse ifelseifに置換されます
それにより1: AST_IF_ELEM以下が大きく変わるためハッシュ値も変わってしまいます

解決方法

function formatElseIf(int $index, Node $child, Node $parent) {
    // AST_STMT_LIST: 132
    // AST_IF: 133 if(全体)
    // AST_IF_ELEM: 535 if(パーツ) (if, elseif, else)
    // 一番最初の要素(if)ではない ⇒ elseif か else if
    if ($child->kind === 535 && $index !== 0) {
        if (is_null($child->children['cond'])) {
            if (isset($child->children['stmts']->children)) {
                if (isset($child->children['stmts']->children[0])) {
                    $stmtChild = $child->children['stmts']->children[0];
                    if ($stmtChild->kind === 133 && is_array($stmtChild->children)) {
                        foreach($stmtChild->children as $node) {
                            // indexの位置から上書きしていく
                            $parent->children[$index++] = $node;
                        }
                    }
                }
            }
        }
    }
}

astで異なるのは

            cond: null
            stmts: AST_STMT_LIST
                0: AST_IF
                    0: AST_IF_ELEM

があるかないかだけなので、 1: AST_IF_ELEMの子要素を同じ部分に置き換える対応をしました。
なお、先にelse ifelseifに一括で置換しても良いです。(正直こちらのほうが楽です)

# 対応例
$ find {ディレクトリパス} -type f | xargs grep -l 'else if' | xargs sed -i '' -e 's/else if/elseif/g'

小ネタ

<?php
if (true) {
    $a = 1;
} else {
    if (true){
        $a = 2;
    }
}

このastはelseifelse ifのどちらかと同じです。どちらでしょうか?

答え

答えはelse ifでした

AST_STMT_LIST
    0: AST_IF
        0: AST_IF_ELEM
            cond: AST_CONST
                name: AST_NAME
                    flags: NAME_NOT_FQ (1)
                    name: "true"
            stmts: AST_STMT_LIST
                0: AST_ASSIGN
                    var: AST_VAR
                        name: "a"
                    expr: 1
        1: AST_IF_ELEM
            cond: null
            stmts: AST_STMT_LIST
                0: AST_IF
                    0: AST_IF_ELEM
                        cond: AST_CONST
                            name: AST_NAME
                                flags: NAME_NOT_FQ (1)
                                name: "true"
                        stmts: AST_STMT_LIST
                            0: AST_ASSIGN
                                var: AST_VAR
                                    name: "a"
                                expr: 2

$a === 22 === $aでハッシュ値が異なる

<?php
$a = 1;
- if (2 === $a) {
+ if ($a === 2) {
}
// ↓ ast_dump
AST_STMT_LIST
    0: AST_ASSIGN
        var: AST_VAR
            name: "a"
        expr: 1
    1: AST_IF
        0: AST_IF_ELEM
            cond: AST_BINARY_OP
                flags: BINARY_IS_IDENTICAL (16)
-                left: 2
-                right: AST_VAR
-                    name: "a"
+                left: AST_VAR
+                    name: "a"
+                right: 2
            stmts: AST_STMT_LIST

ヨーダ記法と呼ばれるものです。弊チームでは禁止しているためyoda_styleルールで入れ替えられます
それによりハッシュ値が変わってしまいます

解決方法

function formatYoda(Node $child) {
    // AST_BINARY_OP: 521 比較演算子
    // AST_CONST: 257
    if ($child->kind === 521) {
        if (isset($child->children['left']) && isset($child->children['right'])) {
            // leftがnumber か constであれば入れ替える
            if (is_numeric($child->children['left'])) {
                $left = $child->children['left'];
                $child->children['left'] = $child->children['right'];
                $child->children['right'] = $left;
            } elseif(isset($child->children['left']->kind) && $child->children['left']->kind === 257) {
                $left = clone $child->children['left'];
                $child->children['left'] = $child->children['right'];
                $child->children['right'] = $left;
            }
        }
    }
}

子要素が比較要素で孫要素のleftに数値か固定値があればrightと入れ替えて対応しました

結果

数百のファイルおよび数万行のプロダクトコードを簡単かつ安全にフォーマットできるようになりました。また、LGTM後の不要なやり取りを削減することに成功し、自動フォーマットの有効化により開発効率の向上に寄与することが出来ました

参考

1
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2