VimでPHPのコードをシュワルツ変換してソートする

(この記事は[ピクシブ株式会社 AdventCalendar 2017](https://qiita.com/advent-calendar/2017/pixiv)の12日目の記事です)

# 今回のあらすじ

<blockquote class="twitter-tweet" data-lang="en"><p lang="ja" dir="ltr">リファクタリングDSLとしてのVim scriptの威力に再び社内がおののいてる</p>&mdash; tad3 (@tadsan) <a href="https://twitter.com/tadsan/status/923078900460040192?ref_src=twsrc%5Etfw">October 25, 2017</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

どうおののかせたかを紹介します。

# 問題

pixivのURLルート定義は以下のような形になっています:[^exp1]

[^exp1]: これは記事の為に簡略化した擬似コードで、実際のものとは異なります。

```php:lib/routes.php
function getUrlRouteMap()
{
    $route_map = [
        '/' => [
            'controller' => 'IndexController',
        ],
        '/discovery' => [
            'controller' => 'DiscoveryController',
        ],
        '/user/:user_id/series' => [
            'controller' => 'UserSeriesIndexController',
            'params' => [
                'user_id' => 'int',
            ],
        ],
        ...
        '/about.php' => [
            'controller' => 'AboutController',
        ],
        '/bookmark.php' => [
            'controller' => 'BookmarkController',
        ],
        ...
    ];
    return $route_map;
}
```

見事に順番がバラバラです[^exp2]。これでは後からコードを読んだ人には何処に何が書かれているか分からない状態で良くありませんし、新しい定義を追加する時にも判断に迷うので各自が好き勝手な場所に書き始めてどんどんカオスになっていきます。数が少なければまだしも、このルート定義の数は800個以上あるという……

[^exp2]: 「URLルーターは無く `htdocs/*.php` だけが有った」→「URLルーターが導入されて極一部のページは手動で移行した」→「残り全てのページは移行スクリプトを書いて処理した」という変遷を経た結果、順番がバラバラになっていました。

というわけで **各ルート定義をURLの辞書順でソートする** というのが今回の目的です。

# 回答

```sh
./sort-routes lib/routes.php
```

```sh:sort-routes
#!/bin/bash
vim -N -u NONE -i NONE -e -s -S "$0.vim" "$@"
```

```vim:sort-routes.vim
/\$route_map = \[/
mark a
normal! %
mark b

'a+1,'b-1 g/'\/.*' => \[/.,/^ \{8}\],$/-1s/\n/XYZZY/
'a+1,'b-1 sort
'a+1,'b-1 s/XYZZY/\r/g

update
qall!
```

# 解説

## Vimでバッチ処理をする

```sh
vim -N -u NONE -i NONE -e -s -S "$0.vim" "$@"
```

これはイディオムです。個々のオプションの意味は以下の通りです:

* `-N` — `-u NONE` するとvi互換モードになって色々と不便なので、それを避ける
* `-u NONE` — `vimrc` を読み込まない。自分の `vimrc` に依存したスクリプトだと他の人が実行できないので
* `-i NONE` — `viminfo` を読み込まない。レジスタの初期値が変動し得るのは避けたいし、上書きするのも避けたい
* `-e -s` — バッチ処理中にバッファの状態を表示しない。この方が速い
* `-S {file}` — Vim起動後に `:source {file}` が実行される

後はVim scriptで望みのバッチ処理を記述すればOKです。

「え? Vim scriptで……?」と怪訝に思うかも知れませんが、Vimはテキストエディタなのでその操作方法はテキスト処理に特化したDSLですし、Vim scriptは普段Vimを使う時に打つ `:e foo` や `:w` の延長に過ぎないので、実際簡単です。

## 下準備: 頻繁に操作する範囲をメモしておく

```vim
/\$route_map = \[/
mark a
normal! %
mark b
```

全てのルート定義を頻繁に編集する事が分かっているので、以後の作業を楽にする為にその範囲をメモしておきます。

1. `/\$route_map = \[/` — 正規表現にマッチする行(=全てのルート定義を包む `[`)までカーソルを移動し
2. `mark a` — その行を後から `'a` で参照できるようメモする
3. `normal! %` — `[` に対応する `]` (=全てのルート定義を包む `]`)までカーソルを移動し
4. `mark b` — その行を後から `'b` で参照できるようメモする

具体的には以下の範囲がメモされます:

```php
function getUrlRouteMap()
{
    $route_map = [                             // 'a
        '/' => [                               // ---.
            'controller' => 'IndexController', //    |-- 実際にソートしたい範囲
        ],                                     //    |
        ...                                    // ---'
    ];                                         // 'b
    return $route_map;
}
```

実際にソートしたい範囲は `'a` より1行下かつ `'b` より 1行上ですが、敢えて1行外側をメモしておきます。外側の行ならソートの過程で変化しないのですが、内側の行はソートの過程で増減します。内側の行をメモすると途中で行が消えて `'a` や `'b` で適切な範囲が指定できなくなるからです。


## 本題: VimでPHPのコードをシュワルツ変換してソートする

```vim
'a+1,'b-1 g/'\/.*' => \[/.,/^ \{8}\],$/-1s/\n/XYZZY/
'a+1,'b-1 sort
'a+1,'b-1 s/XYZZY/\r/g
```

Vimには `sort` コマンドがあるので、特定の範囲を行単位でソートするなら `'a,'b sort` で済みます。ところが各ルート定義は複数行に渡っているので、このままだと `sort` では太刀打ちできません。

でも

1. 各ルート定義を1行に変換して
2. `sort` して
3. 各行をルート定義に逆変換する

とすればソート可能です。

### 各ルート定義を1行に変換する

```vim
'a+1,'b-1 g/'\/.*' => \[/.,/^ \{8}\],$/-1s/\n/XYZZY/
```

`{range}g/{pattern}/{command}` で

1. `{range}` の範囲の中にある
2. `{pattern}` にマッチする各行について
3. `{command}` を実行する

事ができます。

なので、この長いコマンドは

1. `'a+1,'b-1` — ソートしたい範囲の中にある
2. `/'\/.*' => \[/` — 各ルート定義の開始行について
3. `.,/^ \{8}\],$/-1s/\n/XYZZY/` — 何か凄い事をする

という意味になります。

`.,/^ \{8}\],$/-1s/\n/XYZZY/` は一瞥しただけだと謎のコマンドですが、実態は `{range}s/{pattern}/{replacement}/` なので、単なる文字列置換です。具体的には

1. `.,/^ \{8}\],$/-1` — カーソル行(=ルート定義の開始行)から正規表現にマッチする行の直前の行(=ルート定義の最後から2番目の行)について
2. `\n` — 改行文字を
3. `XYZZY` — 通常のソースコードには絶対に現れない文字列に置き換える[^xyzzy]

[^xyzzy]: この記事ではコードとソート過程の分かり易さの為に `XYZZY` を使っています。実務では絶対に現れない文字として `^P` (0x10) を使っていました。

となります。

実行結果は以下のようになります:

```php
function getUrlRouteMap()
{
    $route_map = [
        '/' => [XYZZY            'controller' => 'IndexController',XYZZY        ],
        '/discovery' => [XYZZY            'controller' => 'DiscoveryController',XYZZY        ],
        '/user/:user_id/series' => [XYZZY            'controller' => 'UserSeriesIndexController',XYZZY            'params' => [XYZZY                'user_id' => 'int',XYZZY            ],XYZZY        ],
        ...
        '/about.php' => [XYZZY            'controller' => 'AboutController',XYZZY        ],
        '/bookmark.php' => [XYZZY            'controller' => 'BookmarkController',XYZZY        ],
        ...
    ];
    return $route_map;
}
```

### ソートする

```vim
'a+1,'b-1 sort
```

これは見た通りですね。

実行結果は以下のようになります:

```php
function getUrlRouteMap()
{
    $route_map = [
        '/' => [XYZZY            'controller' => 'IndexController',XYZZY        ],
        '/about.php' => [XYZZY            'controller' => 'AboutController',XYZZY        ],
        '/bookmark.php' => [XYZZY            'controller' => 'BookmarkController',XYZZY        ],
        '/discovery' => [XYZZY            'controller' => 'DiscoveryController',XYZZY        ],
        '/user/:user_id/series' => [XYZZY            'controller' => 'UserSeriesIndexController',XYZZY            'params' => [XYZZY                'user_id' => 'int',XYZZY            ],XYZZY        ],
        ...
    ];
    return $route_map;
}
```

### 各行をルート定義に逆変換する

```vim
'a+1,'b-1 s/XYZZY/\r/g
```

逆変換は `g` を駆使したややこしい行指定がしなくて良いので簡単です。

実行結果は以下のようになります:

```php
function getUrlRouteMap()
{
    $route_map = [
        '/' => [
            'controller' => 'IndexController',
        ],
        '/about.php' => [
            'controller' => 'AboutController',
        ],
        '/bookmark.php' => [
            'controller' => 'BookmarkController',
        ],
        '/discovery' => [
            'controller' => 'DiscoveryController',
        ],
        '/user/:user_id/series' => [
            'controller' => 'UserSeriesIndexController',
            'params' => [
                'user_id' => 'int',
            ],
        ],
        ...
    ];
    return $route_map;
}
```

# 余談

実務で遭遇した例はもう一段ややこしく、一部のルート定義に対して以下のようにコメントが付いていました:


```php
function getUrlRouteMap()
{
    $route_map = [
        ...
        // 旧URLのサポート用
        // TODO: #123 がマージされたらこれは消す
        '/discover' => [
            'redirect' => '/discovery',
        ],
        '/discovery' => [
            'controller' => 'DiscoveryController',
        ],
        ...
    ];
    return $route_map;
}
```

つまり、コメントも維持しつつソートをする必要があったという事です。

さらに、今回の記事の問題は氷山の一角に過ぎず、これに先立って

* ルート定義は一部しか存在せず、残りは `htdocs/*.php` が直接存在する状態だった。ルート定義を生成しつつ `htdocs/*.php` を消す必要があった
* `htdocs/*.php` の中にコントローラークラスが記述されていた。ルート定義を生成する前に、クラス定義を別ファイルに分離する必要があった
* 一部のコントローラークラスは名前が重複していた為、一意な名前に変更する必要があった
* `htdocs/*.php` の中身がベタなPHPスクリプトになっているものが少なからず存在していた。そういうものはコントローラークラスの体裁にまとめ直す必要があった

という楽しいリファクタリングが山盛りでした。もちろん全てVimで解決しました。

# 告知

[ピクシブ株式会社](https://www.pixiv.co.jp/)では、このように大量のファイルを高速にリファクタリングするのが好きなエンジニア・アルバイトを募集しています。使用エディタは問いません。

明日は @Ragg がRailsアプリのCSS設計の知見を披露してくれます。お楽しみに。