はじめに
「ソースコード全てフォーマットしたい」
「でも、テストケースが少ないからフォーマットしてデグレしてないか分からない」
「工数も限られているからできるだけ楽にチェックしたい」
これらはphp-astを利用すれば可能です。
ただし、長期間にわたり運用されているプロダクトでは、容易に対処できない問題もあります。私が試した結果うまくいかなかったことと、それを解決する方法について共有したいと思います。
注意
掲載されている内容や情報を利用することにより、直接または間接的に生じたいかなる問題、トラブル、損害に対しても、一切の責任を負いません。ご利用は自己責任でお願いいたします。
背景
php-cs-fixerを導入しましたが、フォーマットされた変更が修正と混ざり、レビューが難しくなってしまいました。そのため、LGTMをもらった後に修正ファイルをフォーマットして、再度レビューを依頼する運用を採用していました。しかし、この方法ではフォーマッタの真価を生かせていないと感じ、どうにか改善できないか模索していました。
最初にすべてのファイルを一斉にフォーマットするアプローチも考えましたが、自動テストのカバレッジが低く、変更されたファイル数が多いため、「フォーマット後に問題がないか」を確認することが難しい状況でした。
ソースコード
実際のコードを動かしながらいろいろ試したい方は以下から確認可能です
https://github.com/soshi-ikegami/php-ast-check
方法
後述している参考記事にもありますがphp-astというライブラリを利用します
- php-astでastを生成
- astをmd5でハッシュ化しハッシュ値を算出
- フォーマット前後のハッシュ値を比較し違いがないことを確認
うまくいかなかったこと & 解決方法
フォーマット前後で挙動は同じでも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 if
と elseif
でハッシュ値が異なる
<?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 if
はelseif
に置換されます
それにより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 if
をelseif
に一括で置換しても良いです。(正直こちらのほうが楽です)
# 対応例
$ 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はelseif
とelse 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 === 2
と 2 === $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後の不要なやり取りを削減することに成功し、自動フォーマットの有効化により開発効率の向上に寄与することが出来ました