【PHP】1クリックで外部クラスのコードを表示できるコードビューワーを作ってみた

僕はPHPのコードリーディングをするときにGoogle Chromeを使っています。
メリットとしては
- 同じクラス内の違うメソッドを参照するときに、別タブで同じファイルが開ける
- 間違って文字を追加or消去→取り消しの手間がない
などがあります。

ですが、別クラスのファイルを開くときには名前空間を含めたクラス名をコピペして.phpを追加しなければいけないのが面倒でした。

なので**1クリック**で目当てのファイルを表示できるビューワーを作ってみました。
[githubのリンク](https://github.com/ryota-adr/one-click-viewer)

## コード解説
```php
$autoloader = 'vendor/autoload.php';

if (file_exists($autoloader)) {
    try {
        require($autoloader);
    } catch(Throwable $e) {
        $code = "invalid namespace";
        goto output;
    } 
} else {
    $code = "Invalid file path";
    goto output;
}
```

`autoload.php`ファイルが存在しない、もしくはrequireに失敗すればgoto文で`output:`に飛びます。

```php
$url = (empty($_SERVER['HTTPS']) ? 'http://' : 'https://').$_SERVER["HTTP_HOST"] . strtok($_SERVER["REQUEST_URI"],'?');
```

クエリ文字列を抜いたURLです。

```php
// get code from namespace
if (isset($_GET['ns'])) {
    $namespace = $_GET['ns'];
    try {
        $this_file = (new ReflectionClass($namespace))->getFileName();
        $code = @file_get_contents($this_file);

        preg_match('/^(class|interface|abstract class|trait) (\w+) /m', $code, $match_class);
        if (isset($match_class[2])) {
            $this_class = $match_class[2];
        }

        $pat_this_ns = '/namespace (.*?);/';
        preg_match('/namespace (.*?);/', $code, $match_this_ns);
        if (isset($match_this_ns[1])) {
            $this_ns = $match_this_ns[1];
        }
    } catch (Throwable $e) {
        $code = false;
    }
} else {
    $code = false;
}
?>
```

クエリ文字列`ns`の名前空間を含めたクラス名を取得して、`ReflectionClass`クラスを使ってクラスのファイルパスを取得し、`$code`にコードを代入します。
そして正規表現を使い`$this_class`に名前空間を除いたクラス名を代入します。
`new ReflectionClass()'でエラーが起きた場合は`$code`に`false`を代入します。

```html
<div class="namespace">
    <form action="<?php basename(__FILE__) ?>" method="GET">
        <input type="text" name="ns" value="<?php if (isset($namespace)) echo $namespace ?>" size="60" class="ns">
        <button>SHOW</button>
    </form>
</div>
```

HTMLのテキストボックスにです。
クエリ文字列`ns`に文字列があればここに表示されます。

```php
if ($code === false) {
    $code = "invalid namespace";
    goto output;
}

$code = htmlspecialchars($code);
```

`$code`が`false`ならgoto文で`output:`に飛びます。
そうでなければ`htmlspecialchars()`でHTML エンティティ化します。

```php
//replace uses    
preg_match('/namespace[\s][A-Za-z\\\]+?;[\s\S]+?(class|interface|abstract class|trait)/', $code, $match_uses_str);
$uses_str = $match_uses_str[0];
```

正規表現で`use`文を取得します。

```php
preg_match_all('/use ([a-z\\\]+?);/i', $uses_str, $match_uses);
$classes_with_ns = $match_uses[1];
```

`use`文から`as`を使っていない名前空間を含めたクラス名を取得し`$classes_with_ns`配列に入れます。

```php
$replace_uses_str = $uses_str;
$class_arr = [];
foreach ($classes_with_ns as $class_with_ns) {
    try {
        $path = (new ReflectionClass($class_with_ns))->getFileName();
        if ($path) {
            $split = explode('\\', $class_with_ns);
            $end = end($split);
            $class_arr[$end] = $class_with_ns;

            $replace_uses_str = preg_replace('/'.preg_quote($class_with_ns).';/', "<a href=\"$url?ns=$class_with_ns\">$class_with_ns</a>;", $replace_uses_str);
        }
    } catch (Exception $e) {}
}
$code = str_replace($uses_str, $replace_uses_str, $code);
```

`$replace_uses_str = $uses_str;`で同じ文字列を作ります。
置換する時に`$uses_str`で検索し、`$replace_uses_str`に置換します。

そして`$classes_with_ns`に入っているクラス名を置換していきます。
`new ReflectionClass()=.getFileName()`でファイルがあるクラスだけ置換します。

`$class_arr'は[クラス名　=> 名前空間を含めたいクラス名]の配列です。
これは他の部分でクラス名を置換するのに使います。

```php
//replace uses with as
preg_match_all('/use (([A-Za-z\\\]+?) as ([A-Za-z\\\]+?));/', $replace_uses_str, $match_use_as);

$all_ns_alias_arr = [];
for ($i = 0; $i < count($match_use_as[0]); $i++) {
    $all_ns_alias_arr[] = array_column($match_use_as, $i);
}
$all_ns_alias_arr = array_map(function($arr) {
    return [
        "all" => $arr[1],
        "ns" => $arr[2],
        "alias" => $arr[3]
    ];
} ,$all_ns_alias_arr);

$alias_ns = array_combine(
    array_column($all_ns_alias_arr, 'alias'), 
    array_column($all_ns_alias_arr, 'ns')
);
```

`as`でエイリアス化したクラス名を置換するための配列を作ります。
中身は[エイリアス => 名前空間を含めたクラス名]になっています。

```php
$replace_use_with_as_str = $replace_uses_str;

foreach ($all_ns_alias_arr as $all_ns_alias) {
    try {
        $path = (new ReflectionClass($all_ns_alias['ns']))->getFileName();
        if ($path) {
            $replace_use_with_as_str = str_replace($all_ns_alias['all'], "<a href=\"$url?ns=$all_ns_alias[ns]\">$all_ns_alias[all]</a>", $replace_use_with_as_str);
        }
    } catch (Exception $e) {}
}
$code = str_replace($replace_uses_str, $replace_use_with_as_str, $code);
```

先ほどと同じように置換していきます。

```php
//replace extends and implements
preg_match('/^(class|interface|abstract class|trait) \w+? (extends|implements) .+[\r\n]/m', $code, $match_ext_imp);

if (!empty($match_ext_imp[0])) {
    $ext_imp_str = $match_ext_imp[0];
    preg_match_all('/\\\?([A-Z][a-z]+\\\?)+/', $ext_imp_str, $match_exts_imps);
    if (!empty($exts_imps =  $match_exts_imps[0])) {    
        $idx = array_search($this_class, $exts_imps);
        if ($idx !== false) {
            array_splice($exts_imps, $idx, 1);
        }
        
        $replace_ext_imp_str = $ext_imp_str;
        foreach ($exts_imps as $ext_imp) {
            if (isset($class_arr[$ext_imp])) {
                $replace_ext_imp_str = preg_replace('/ '.$ext_imp.'( |,|[\r\n])/', " <a href=\"$url?ns=$class_arr[$ext_imp]\">$ext_imp</a>$1", $replace_ext_imp_str);
            } elseif (isset($alias_ns[$ext_imp])) {
                $replace_ext_imp_str = preg_replace('/ '.$ext_imp.'( |,|[\r\n])/', " <a href=\"$url?ns=$alias_ns[$ext_imp]\">$ext_imp</a>$1", $replace_ext_imp_str);
            } else {
                try {
                    $ext_imp_with_ns = $this_ns.'\\'.trim($ext_imp, '\\');
                    if ((new ReflectionClass($this_ns.'\\'.trim($ext_imp, '\\')))->getFileName()) {
                        $replace_ext_imp_str = str_replace($ext_imp, "<a href=\"$url?ns=$ext_imp_with_ns\">$ext_imp</a>", $replace_ext_imp_str);
                    }
                } catch (Exception $e) {}
            }
        }
        $code = str_replace($ext_imp_str, $replace_ext_imp_str, $code);
    }
}
```

親クラスとインターフェースの置換です。
正規表現でクラス名を取得し配列に入れて{foreach`で回します。
`use`文にあるクラス以外は、表示しているファイルの名前空間を使ってファイルが存在すれば置換します。

クラスメソッドのドキュメンテーション以外は同じように置換します。

```php
//replace doc
preg_match_all('/\/\*\*[\s\S]+?\*\//', $code, $match_docs_str);
if (isset($match_docs_str[0])) {
    $docs_str = $match_docs_str[0];
    foreach ($docs_str as $doc_str) {
        preg_match_all('/@\w+? +(\\\?([A-Z][a-z]+\\\?)+)/', $doc_str, $match_classes_with_ns);
        if (isset($match_classes_with_ns[1])) {
            $classes_with_ns = $match_classes_with_ns[1];
            $classes_with_ns = array_unique($classes_with_ns);
            $replace_doc = $doc_str;
            foreach ($classes_with_ns as $class_with_ns) {
                try {
                    if ((new ReflectionClass($class_with_ns))->getFileName()) {
                        $replace_doc = str_replace($class_with_ns, "<a href=\"$url?ns=$class_with_ns\">$class_with_ns</a>", $replace_doc);

                        $split = explode('\\', $class_with_ns);
                        $end = end($split);
                        $class_arr[$end] = $class_with_ns;
                    }
                } catch (Exception $e) {}
            }
            $code = str_replace($doc_str, $replace_doc, $code);
        }
    }
}
```

[PDR-4](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader-examples.md)に準拠したドキュメンテーションには完全修飾クラス名が書いてあるので`$class_arr`と`$alias_ns`を条件分岐に使いません。
完全修飾クラス名を配列に入れて、`foreach`で回して置換します。

置換可能なものを全て置換したら出力します。

```php
<?php
//print dir path
if (isset($this_file)) {
    $dir = dirname($this_file);
    echo "<span class=\"dir\">$dir</span>";
}
?>
```
名前空間が分からないクラスを探すために、表示しているファイルのディレクトリのパスを出力します。
JavaScriptで、クリックするとクリップボードにコピーできるようになっています。

```php
<div>
<table>
<?php
//print code with row num
$lines = explode("\n", $code);
$count = count($lines);
$i = 1;
foreach ($lines as $line) {
    echo '<tr><td><pre><span class="num">'.($i++).'<span></pre></td><td><pre>'.$line.'</pre></td></tr>';
}
$div_width = (strlen($count) + 1) * 9; 
?>
</table>
</div>
```
`table`タグで行番号と一緒にコードを出力します。
行番号はCSSで選択できないようなっているので、コードだけをコピーできるようになっています。

## 参考
[PHPで現在アクセスされているページのURLを取得する](https://qiita.com/tsujimomo/items/c293d15e34646a826266)
[JavaScript でテキストをクリップボードへコピーする方法](https://webllica.com/copy-text-to-clipboard/)