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

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

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

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

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

## 方法

後述している参考記事にもありますがphp-astというライブラリを利用します
1. php-astでastを生成
1. astをmd5でハッシュ化しハッシュ値を算出
1. フォーマット前後のハッシュ値を比較し違いがないことを確認

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

### array()と[]でハッシュ値が異なる
```diff_php
<?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](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/rules/array_notation/array_syntax.rst#rule-array_syntax)ルールにより`array()`が`[]`に置換されます
それにより`flags`という要素が変わるためハッシュ値も変わってしまいます
### 解決方法
```php
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のスペースによってハッシュ値が異なる
```diff_php
<?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](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/rules/phpdoc/phpdoc_align.rst)ルールにより削除されました
それにより`docComment`という要素の内容が変わるためハッシュ値も変わってしまいます

### 解決方法
```php
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](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/rules/phpdoc/no_empty_phpdoc.rst#rule-no_empty_phpdoc)ルールにより削除されてしまうのでnullをセットしました

### 定数が大文字と小文字でハッシュ値が異なる
```diff_php
<?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に置換されるためハッシュ値が変わってしまいます

### 対応方法
```php
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 if` と `elseif` でハッシュ値が異なる
```diff_php
<?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](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/rules/control_structure/elseif.rst#rule-elseif)ルールにより`else if`は`elseif`に置換されます
それにより`1: AST_IF_ELEM`以下が大きく変わるためハッシュ値も変わってしまいます

### 解決方法
```php
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で異なるのは
```php
            cond: null
            stmts: AST_STMT_LIST
                0: AST_IF
                    0: AST_IF_ELEM
```
があるかないかだけなので、` 1: AST_IF_ELEM`の子要素を同じ部分に置き換える対応をしました。
なお、先に`else if`を`elseif`に一括で置換しても良いです。(正直こちらのほうが楽です)
```shell
# 対応例
$ find {ディレクトリパス} -type f | xargs grep -l 'else if' | xargs sed -i '' -e 's/else if/elseif/g'
```

#### 小ネタ

```php
<?php
if (true) {
    $a = 1;
} else {
    if (true){
        $a = 2;
    }
}
```
このastは`elseif`と`else if`のどちらかと同じです。どちらでしょうか？

<details><summary>答え</summary>

答えは`else if`でした
```php
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
```

</details>

### `$a === 2` と `2 === $a`でハッシュ値が異なる

```diff_php
<?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](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/rules/control_structure/yoda_style.rst)ルールで入れ替えられます
それによりハッシュ値が変わってしまいます

### 解決方法

```php
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後の不要なやり取りを削減することに成功し、自動フォーマットの有効化により開発効率の向上に寄与することが出来ました

## 参考
- [20 万行超のコードベースをテストせずにリファクタリングリリースした話](https://tech-blog.monotaro.com/entry/2018/09/26/142451)
- [モノタロウさんがすごいなと思ったのでphp-astを試してみた](https://qiita.com/tetsunosukeito/items/c0e99a120414de226480)
- [PHP のリファクタリングがテスト不要か判定するアクションを作りました](https://zenn.dev/snowcait/articles/30f44683fed179)