多言語化の対応を、PHP-Parserでコード置換＆静的解析でサポートする話


こんにちわ。 [OPENLOGI AdventCalendar](https://qiita.com/advent-calendar/2020/openlogi) 6日目です。

オープンロジでは[昨年インドネシアで実証実験](https://corp.openlogi.com/news/20190515-indonesia/) を行いました。それにあたってシステムの多言語化行ったのですが、その際に利用した[PHP-Parser](https://github.com/nikic/PHP-Parser)の活用について少し紹介したいと思います。

# 前提
## Laravelの多言語の仕組み

```php:lang/en/messages.php
<?php

return [
    'welcome' => 'Welcome to our application',
];
```

のような言語ファイルを用意し、

```php
echo __('messages.welcome');
```

と記述することで、多言語の対応を行うことができます。詳しくは https://readouble.com/laravel/7.x/en/localization.html あたりを参照。

## やりたいこと

- その１、翻訳対象の文字列を全て `__()` で囲いたい。
- その２、翻訳文言が全て言語ファイルに定義されていることを担保したい。

と言う感じです。

# 翻訳対象の文字列を全て `__()` で囲いたい

さて本題。
ざっくり言うと、Laravel内で定義した全ての日本語（っぽい文字列）を、 `__()` で囲いたい。と言うのが要件です。まぁこれだけだとPHPの言語仕様上許されないケースもありますし、多言語化をする必要のないケースなどあります。
が、そういったイレギュラーへの対応は一旦無視し、分かりやすさ重視ということで細かいところは端折って書いきますのでご了承ください。

簡単な具体例をだしてみます。まずこれが変更したいソースコード。

```php:before.php
<?php
class Sample
{
    public function test()
    {
        echo 'aiueo';

        echo 'あいえうお';
        
        echo __('かきくけこ');
    }
}
```

そしてこっちが期待するコードです。

```php:after.php
<?php
class Sample
{
    public function test()
    {
        echo 'aiueo';

        echo __('あいえうお');
        
        echo __('かきくけこ');
    }
}
```

多言語のキーは敢えて元の日本語文字列としてます。元の仕様通り `messages.あいうえお` ってキーに変換してあげてもい良い。また、一括で `Sample.1` のようなキーに変換してあげても良いかもしれませんが、分かりやすさ重視、ということで。デフォルト文言をキーにしてます。
さて、ではまずは上側ファイルをASTでどのような構造となるかを確認しましょう。

```php
function dumpAST($filePath)
{
    $lexer = new Emulative([
        'usedAttributes' => [
            'comments', 'startLine', 'endLine', 'startTokenPos', 'endTokenPos',
        ],
    ]);
    $node = (new Php7($lexer))->parse(file_get_contents(new SplFileInfo($filePath)));
    echo (new NodeDumper())->dump($node) . PHP_EOL;
}
```

するとこんな感じになります。（読まなくて良いよ！）

```
Stmt_Class(
    flags: 0
    name: Identifier(
        name: Sample
    )
    extends: null
    implements: array(
    )
    stmts: array(
        0: Stmt_ClassMethod(
            flags: MODIFIER_PUBLIC (1)
            byRef: false
            name: Identifier(
                name: test
            )
            params: array(
            )
            returnType: null
            stmts: array(
                0: Stmt_Echo(
                    exprs: array(
                        0: Scalar_String(
                            value: aiueo
                        )
                    )
                )
                1: Stmt_Echo(
                    exprs: array(
                        0: Scalar_String(
                            value: あいえうお
                        )
                    )
                )
                2: Stmt_Echo(
                    exprs: array(
                        0: Expr_FuncCall(
                            name: Name(
                                parts: array(
                                    0: __
                                )
                            )
                            args: array(
                                0: Arg(
                                    value: Scalar_String(
                                        value: かきくけこ
                                    )
                                    byRef: false
                                    unpack: false
                                )
                            )
                        )
                    )
                )
            )
        )
    )
)

```

なるほどなるほど。ってことで、

```
0: Scalar_String(
    value: あいえうお
)
```

を、

```
Expr_FuncCall(
    name: Name(
        parts: array(
            0: __
        )
    )
    args: array(
        0: Arg(
            value: Scalar_String(
                value: あいうえお
            )
        )
    )
)
```

とできたら勝ちですね。

さて、PHP-Parserでは、ASTの木構造について各ノードを処理する**Visitor**を実装します。今回上記を実現するための**Visitor**はこんな感じで書いてみました。

ポイントとしては

- A ) 文字列（`Node\Scalar\String_`） がASCIIでなければ、関数（`Node\Expr\FuncCall`）で囲った関数ノードに置き換える
- B ) すでに該当の関数で囲われている場合は `NodeTraverser::DONT_TRAVERSE_CHILDREN` を利用してそのノードは対象外とする
- C ) Aで囲った関数ノードの内部も同様に対象外とする

```php:WrapNonAsciiTextVisitor.php
<?php
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use SplFileInfo;

final class WrapNonAsciiTextVisitor extends NodeVisitorAbstract
{
    /** @var SplFileInfo  */
    private $splFileInfo;

    /** @var string */
    private $functionName;

    /** @var array */
    private $modifications = []; // ログ用。変換したやつをあとで一覧にしたいから。

    private $wrapNode;

    function __construct(SplFileInfo $splFileInfo, string $functionName = '__')
    {
        $this->splFileInfo = $splFileInfo;
        $this->functionName = $functionName;
    }

    public function enterNode(Node $node)
    {
        // C) wrapNodeの内部はなにもしない
        if ($this->wrapNode) 
            return NodeTraverser::DONT_TRAVERSE_CHILDREN;
        }
        
        // B) すでに関数で囲われているものも対象外
        if ($node instanceof Node\Expr\FuncCall) {
            if (isset($node->name->parts) && $node->name->parts === [$this->functionName]) {
                return NodeTraverser::DONT_TRAVERSE_CHILDREN;
            }
        }

        // A) 文字列でASCIIでない場合は、関数に置き換える
        if ($node instanceof Node\Scalar\String_) {
            if (!$this->isAscii($node->value)) {

                $this->modifications[] = [
                    'start_line' => $node->getStartLine(),
                    'value' => $node->value,
                ];

                $this->wrapNode = new Node\Expr\FuncCall(new Name($this->functionName), [
                    new Node\Arg(new Node\Scalar\String_($node->value))
                ]);
                // C) 置き換えたノードがわかるように一時的に保持しておく
                return $this->wrapNode;
            }
        }

        return $node;
    }

    public function getModifications() {
        return $this->modifications;
    }

    public function leaveNode(Node $node)
    {
        // C) 置き換えたノードを出る時に初期化
        if ($this->wrapNode === $node) {
            $this->wrapNode = null;
        }

        return parent::leaveNode($node);
    }

    private function isAscii($text)
    {
        // とりあえずASCII以外とする。
        return mb_check_encoding($text, 'ASCII');
    }
}
```

wrapNodeをわざわざ保持しているのは、`Scalar_String` => `Expr_FuncCall(Scalar_String)` と置換しているため。無限にラップし続けるのを防ぐための機構。

これで、AST上の表現として、コードの書き換えの準備がととのいました。
あとはASTをソースコードに変換してファイルを上がいて行けばよいのですが、上のASTの表現見ると、空白行のようなもの、ないですよね。一度ASTに変換したものをコードに戻す場合、**空白行が削られたりする**のですが、元の**フォーマットを維持するように出力**しています。

また、変更した箇所をVisitorで保持しておいたので、CSVっぽくログを出すようにしてます。

```php
public function transform($splFileInfo) {
        $lexer = new Emulative([
            'usedAttributes' => [
                'comments', 'startLine', 'endLine', 'startTokenPos', 'endTokenPos',
            ],
        ]);

        $parser = new Php7($lexer);

        // 元のフォーマットを保持する
        $statementPreservingTraverser = new NodeTraverser();
        $statementPreservingTraverser->addVisitor(new CloningVisitor());
        $oldStatements = $parser->parse(file_get_contents($splFileInfo))
        $newStatements = $statementPreservingTraverser->traverse($oldStatements);

        // 実際の変換処理
        $visitor = new WrapNonAsciiTextVisitor($splFileInfo);
        $nodeTraverser = new NodeTraverser();
        $nodeTraverser->addVisitor($visitor);
        $nodeTraverser->traverse($newStatements);

        // 変換したやつの一覧を出しておく。ただのログ
        foreach($visitor->getModifications() as $modification) {
            fputcsv(STDOUT, array(
                $this->splInfo->getPathname(),
                $modification['start_line'] ?? '',
                $modification['value'] ?? '',
            ));
        }

        // 変換後のコードでファイルを更新する
        $newCode = (new Standard)->printFormatPreserving($newStatements, $oldStatements, $lexer->getTokens());
        file_put_contents($this->splInfo->getRealPath(), $newCode);
}
```

あとは、特定のディレクトリを全てなめて実行すればOK!

```php
function wrapNonAsciiText(string $dir)
{
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir,
            FilesystemIterator::CURRENT_AS_FILEINFO |
            FilesystemIterator::KEY_AS_PATHNAME |
            FilesystemIterator::SKIP_DOTS
        )
    );

    /** @var SplFileInfo $fileinfo */
    foreach ($iterator as $fileinfo) {
        transform($fileInfo);
    }
}
```

ここまでの実装は、 https://github.com/haradakunihiko/php-code-mod にあげているので参考にしてください。
上述しましたが、実際にこの処理だけではうまくいかないケースが多々あります。
例えばメンバ変数の初期値に関数を利用してしまう、とか。まぁその辺りは地道にVisitorを拡張していきましょう。


# 翻訳文言が言語ファイルに存在することをチェックしたい
はい。
てなわけで一旦 `__` を全部にかませることができました。
あとは今後の開発者がやるべきことを忘れないようにチェックですね。例えば、**`__()`を利用するの忘れた** とか、 **`__()`は使ったんだけど、言語ファイルに追記するの忘れた** ってやつですね。多言語化の一番の課題って結局この辺なのかなと思ってます。

どちらも上の応用でできるとおもいますが、前者はイレギュラーパターンを考えると単純にアサートするのは難しそう。後者は要件明確です。言語ファイルに全ての文言が存在するかを**ユニットテスト**で担保しましょう。
ということで、 `__('文字列')` となっている文字列を抽出するためのVisitor。

```php
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;

class TranslationMessageVisitor extends NodeVisitorAbstract
{
    private $translateFunction;
    private $keys;

    public function __construct(string $translateFunction)
    {
        $this->translateFunction = $translateFunction;
        $this->keys = collect();
    }

    public function enterNode(Node $node)
    {
        if ($node instanceof Node\Expr\FuncCall) {
            if (isset($node->name->parts) && (
                $node->name->parts === [$this->translateFunction]
            )) {
                /** @var Node\Arg $key */
                $keyArg = array_get($node->args, '0');
                if ($keyArg instanceof Node\Arg) {
                    /** @var Node\Scalar\String_ $key */
                    $key = $keyArg->value;
                    if ($key instanceof Node\Scalar\String_) {
                        $this->keys->push($key->value);
                    }
                }
            }
        }

        return $node;
    }

    public function getKeys()
    {
        return $this->keys;
    }
}
```

これで、全ての`__()`に渡しているキーが取得できます。あとはこれらのキーに対して、言語の翻訳があるかどうかをチェックすればOK。
Laravelの仕組みであればこんな感じ。

```php3
    $this->assertTrue(app('translator')->hasForLocale("messages.{$key}", $lang));
```

という風にユニットテストを書いてます。

# 最後に
最初から多言語化を考慮してあればよいのですが、そうも限らないですからね・・
ということで、PHP-Parserを利用して、PHPコードの一括置換や、静的解析をしてユニットテストへ組み込む例を紹介しました。
実際にはもう少し工夫 & 拡張して

- （上に書いたように）定数やフィールドの初期化は除外するとか
- 例外に利用されている部分だけを抜き出して特別な関数に置き換えるとか
- `__()` で囲われたキーを、各翻訳の言語ファイルに出力するところまでを半自動化するとか

ってことを追加でやってます。
IDEの機能を利用したり正規表現を使うのも手頃で良いですが、意外とASTを利用した置換や静的解析もやってみると簡単にできるので、おすすめです。

# リンク
- [Walking the AST](https://github.com/nikic/PHP-Parser/blob/master/doc/component/Walking_the_AST.markdown)
- [Pretty printing](https://github.com/nikic/PHP-Parser/blob/master/doc/component/Pretty_printing.markdown)