PHP
Vim
リファクタリング

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

(この記事はピクシブ株式会社 AdventCalendar 2017の12日目の記事です)

今回のあらすじ

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

問題

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

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;
}

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

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

回答

./sort-routes lib/routes.php
sort-routes
#!/bin/bash
vim -N -u NONE -i NONE -e -s -S "$0.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でバッチ処理をする

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

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

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

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

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

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

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

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

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

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

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

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

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

'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行に変換する

'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 — 通常のソースコードには絶対に現れない文字列に置き換える3

となります。

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

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;
}

ソートする

'a+1,'b-1 sort

これは見た通りですね。

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

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;
}

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

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

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

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

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;
}

余談

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

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で解決しました。

告知

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

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


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

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

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