Created
June 16, 2012 19:11
-
-
Save mindplay-dk/2942266 to your computer and use it in GitHub Desktop.
Migrate a PHP codebase to use namespaces.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Namespace Migration Script | |
* | |
* @author Rasmus Schultz <[email protected]> | |
* @license http://www.gnu.org/licenses/gpl-3.0.txt | |
* | |
* This script will scan through an entire PHP codebase and rewrite the | |
* scripts, adding a namespace clause based on the directory structure, | |
* and adding use-statements as needed. | |
* | |
* This is currently in-development and incomplete - it currently handles: | |
* | |
* - The "new" operator. | |
* - The "class" and "extends" keywords. | |
* - Statics method-calls, variables and constants. | |
* | |
* It does not yet write out the modified scripts - for the moment, this | |
* is basically set up to evaluate whether this approach is useful at all. | |
* | |
* If you're using "magic tricks", such as referencing types dynamically | |
* using strings, this cannot (and never will) handle that. | |
* | |
* Run from a browser - expect the script to run pretty slow depending | |
* on the size of your codebase. | |
* | |
* Configure the base path and filenames near the bottom of this script. | |
* At the moment, this is configured to migrate ProcessWire - it should | |
* be dropped into the root-folder of a site, alongside the "wire" folder. | |
*/ | |
/** | |
* Represents a PHP token | |
*/ | |
class Token | |
{ | |
public $id = 0; | |
public $data = ''; | |
public $type = null; | |
public function __get($name) | |
{ | |
if ($name == 'name') { | |
return $this->type === null ? '?' : token_name($this->type); | |
} | |
throw new Exception('undefined property '.$name); | |
} | |
} | |
class TokenException extends Exception {} | |
/** | |
* Codebase migration tool. | |
* | |
* Note that only one source-code file can be loaded at a time - but initially, when | |
* scanFiles() is called, every file registered in $files will be loaded, one at a | |
* time, so that type-names can be registered in $names. | |
*/ | |
class Migrator | |
{ | |
public $basePath; // root folder of codebase | |
public $files = array(); // list of script file paths (relative to $basePath) | |
public $rootNamespace; // root namespace | |
public $capitalize = true; // whether to capitalize namespaces | |
public $names = array(); // name index (where old name => new name) | |
public $warnings = array(); // any warnings generated while scanning the codebase | |
public $notices = array(); // any notices generated while scanning the codebase | |
public $corrections = array(); // corrections generated for certain warnings | |
public $file; // path of currently loaded file | |
public $tokens; // list of tokens in currently loaded file | |
public $namespace; // namespace of currently loaded file | |
public $hasTypes = false; // flag indicating whether the currently loaded file defines any types | |
public static $standardTypes; // list of standard types (no warnings are produced for these) | |
public function __construct($basePath) | |
{ | |
$this->basePath = $basePath; | |
$this->addFiles('', '*.php'); | |
} | |
public static function init() | |
{ | |
self::$standardTypes = array_merge( | |
get_declared_classes(), | |
get_declared_interfaces() | |
); | |
} | |
/** | |
* Recursively add files from a given path matching the given mask | |
*/ | |
public function addFiles($path, $mask) | |
{ | |
$prefix = $this->basePath.'/'; | |
if (strlen($path)) { | |
$prefix .= $path.'/'; | |
} | |
foreach (glob($prefix.$mask) as $file) { | |
$this->files[] = substr($file, strlen($this->basePath)+1); | |
} | |
foreach (glob($prefix.'*', GLOB_ONLYDIR) as $subpath) { | |
$this->addFiles(substr($subpath, strlen($this->basePath)+1), $mask); | |
} | |
} | |
/** | |
* Remove files matching the given mask | |
*/ | |
public function removeFiles($mask) | |
{ | |
foreach ($this->files as $index => $path) { | |
if (fnmatch($mask, $path)) { | |
unset($this->files[$index]); | |
} | |
} | |
} | |
/** | |
* Scan all files in the current list of files | |
*/ | |
public function scanFiles() | |
{ | |
sort($this->files); | |
foreach ($this->files as $file) { | |
$this->loadFile($file); | |
} | |
foreach ($this->files as $file) { | |
$this->loadFile($file); | |
$this->fixReferences(); | |
} | |
ksort($this->warnings); | |
ksort($this->notices); | |
ksort($this->corrections); | |
} | |
/** | |
* Load a PHP script from the given path | |
*/ | |
public function loadFile($path) | |
{ | |
if (!in_array($path, $this->files)) { | |
throw new Exception('invalid path: '.$path); | |
} | |
$this->file = $path; | |
$code = file_get_contents($this->basePath . '/' . $path); | |
$this->tokens = $this->createTokens($code); | |
$this->namespace = $this->getNamespaceOfPath($path); | |
$this->indexTypes(); | |
} | |
/** | |
* Map a (relative) path to it's corresponding namespace. | |
*/ | |
protected function getNamespaceOfPath($path) | |
{ | |
$names = explode('/', $path); | |
array_pop($names); | |
if ($this->capitalize) { | |
$names = array_map('ucfirst', $names); | |
} | |
return implode('\\', $names); | |
} | |
/** | |
* Create token from the given PHP source code | |
*/ | |
protected function createTokens($code) | |
{ | |
$tokens = array(); | |
foreach (token_get_all($code) as $i => $token) { | |
if (is_array($token)) { | |
$tokens[$i] = new Token; | |
$tokens[$i]->type = $token[0]; | |
$tokens[$i]->data = $token[1]; | |
} else { | |
$tokens[$i] = new Token; | |
$tokens[$i]->data = $token; | |
} | |
$tokens[$i]->id = $i; | |
} | |
return $tokens; | |
} | |
/** | |
* Rename the given type-name (adding the namespace) | |
*/ | |
protected function rename($oldName) | |
{ | |
$pathinfo = pathinfo($this->file); | |
$key = "{$oldName}__FILENAME"; | |
if (strlen($oldName) && ($oldName !== $pathinfo['filename'])) { | |
$this->warnings[$key] = | |
'class-name "' . $oldName . '" is inconsistent with file-name "' . $this->file . '"'; | |
} | |
$newName = $this->rootNamespace; | |
if (strlen($this->namespace)) { | |
$newName .= (strlen($newName) ? '\\' : '') . $this->namespace; | |
} | |
if (strlen($oldName)) { | |
$newName .= (strlen($newName) ? '\\' : '') . $oldName; | |
} | |
/*$this->corrections[$key] = | |
"ren {$this->file} " . str_replace('\\', '/', $newName) . '.php';*/ | |
return $newName; | |
} | |
/** | |
* Indexes interface/class declarations of the current file. | |
*/ | |
protected function indexTypes() | |
{ | |
$count = 0; | |
$line_num = 1; | |
$inc_lines = array(); | |
foreach ($this->tokens as $i => $token) { | |
$line_num += preg_match_all('/[\r]/', $token->data); | |
if ($token->type === T_CLASS || $token->type === T_INTERFACE) { | |
$oldName = $this->parseTypeName($i); | |
$this->names[$oldName] = $this->rename($oldName); | |
$count++; | |
} | |
if (in_array($token->type, array(T_REQUIRE, T_REQUIRE_ONCE, T_INCLUDE, T_INCLUDE_ONCE))) { | |
$inc_lines[] = $line_num; | |
} | |
} | |
if (count($inc_lines)) { | |
$this->notices["{$this->file}__INCLUDE"] = | |
'File "' . $this->file . '" contains include/require-statement(s) in line(s) ' . implode(', ', $inc_lines); | |
} | |
if ($count > 1) { | |
$this->warnings["{$this->file}__COUNT"] = | |
'File "' . $this->file . '" contains ' . $count . ' type-declarations'; | |
} | |
$this->hasTypes = $count > 0; | |
} | |
/** | |
* Fix references: add namespace and use-statements to the script in memory | |
*/ | |
public function fixReferences() | |
{ | |
try { | |
$openToken = $this->nextToken(-1, T_OPEN_TAG); | |
} catch (TokenException $e) { | |
return; // there's no PHP open-tag in this file. | |
} | |
$uses = array(); | |
foreach ($this->tokens as $i => $token) { | |
if ($token->type === T_EXTENDS || $token->type === T_NEW) { | |
$oldName = $this->parseTypeName($i); | |
if (isset($this->names[$oldName])) { | |
$uses[$this->names[$oldName]] = true; | |
} | |
} | |
if ($token->type == T_DOUBLE_COLON) { | |
$oldName = $this->parseTypeName($i); | |
if (isset($this->names[$oldName])) { | |
$uses[$this->names[$oldName]] = true; | |
} | |
} | |
if ($token->type == T_IMPLEMENTS) { | |
$n = 1; | |
do | |
{ | |
$t = $this->tokens[$token->id + $n]; | |
if ($t->type === null) { | |
if (trim($t->data) !== '') { | |
if (trim($t->data !== ',')) { | |
break; | |
} | |
} | |
} else if ($t->type === T_STRING) { | |
$oldName = $t->data; | |
if (isset($this->names[$oldName])) { | |
$uses[$this->names[$oldName]] = true; | |
} else { | |
$uses[$oldName] = true; | |
if (!in_array($oldName, self::$standardTypes)) { | |
$this->warnings["{$this->file}__MAP__{$oldName}"] = 'File "' . $this->file . '" references an unknown type: ' . $oldName; | |
} | |
} | |
} | |
$n++; | |
} | |
while (true); | |
} | |
} | |
if (strlen($this->namespace) && $this->hasTypes) { | |
$openToken->data .= "\nnamespace " . $this->rename('') . ";\n"; | |
} | |
if (count($uses)) | |
{ | |
$openToken->data .= "\n"; | |
foreach ($uses as $use => $true) { | |
$openToken->data .= "use {$use};\n"; | |
} | |
$openToken->data .= "\n"; | |
} | |
} | |
/** | |
* Re-assembles the modified script file currently in memory | |
*/ | |
public function getScript() | |
{ | |
$script = ''; | |
foreach ($this->tokens as $token) { | |
$script .= $token->data; | |
} | |
return $script; | |
} | |
/** | |
* Find the next token of a given type, starting at the given index | |
*/ | |
protected function nextToken($index, $type) | |
{ | |
while (++$index < count($this->tokens)) | |
{ | |
if ($this->tokens[$index]->type === $type) { | |
return $this->tokens[$index]; | |
} | |
} | |
throw new TokenException('next token not found: '.token_name($type)); | |
} | |
/** | |
* Find the previous token of a given type, starting at the given index | |
*/ | |
protected function prevToken($index, $type) | |
{ | |
while (--$index > 0) | |
{ | |
if ($this->tokens[$index]->type == $type) { | |
return $this->tokens[$index]; | |
} | |
} | |
throw new TokenException('previous token not found: '.token_name($type)); | |
} | |
/** | |
* Parse a type-name starting from the given index | |
*/ | |
protected function parseTypeName($index) | |
{ | |
$name = ''; | |
$n = $index + 1; | |
while ($nstoken = $this->tokens[$n++]) { | |
if ($nstoken->type === T_STRING) { | |
$name .= $nstoken->data . '\\'; | |
} else if ($nstoken->type === T_WHITESPACE) { | |
continue; | |
} else if ($nstoken->data !== '\\') { | |
break; | |
} | |
} | |
return trim($name, '\\'); | |
} | |
/** | |
* Dump the tokens currently in memory to a table (for diagnostic purposes) | |
*/ | |
public function dump() | |
{ | |
echo '<table style="font-family:monospace; font-size:12px;">'; | |
foreach ($this->tokens as $token) { | |
echo '<tr><td>'.$token->name.'</td><td>'.htmlspecialchars($token->data).'</td></tr>'; | |
} | |
echo '</table>'; | |
} | |
} | |
Migrator::init(); | |
// ===== Perform Migration ===== | |
$mig = new Migrator(dirname(__FILE__)); | |
$mig->rootNamespace = ''; | |
$mig->capitalize = false; | |
$mig->addFiles('wire', '*.module'); | |
$mig->addFiles('wire', '*.inc'); | |
$mig->removeFiles(basename(__FILE__)); | |
#$mig->removeFiles('index.php'); | |
#$mig->removeFiles('site/*'); | |
$mig->removeFiles('templates/*'); | |
$mig->removeFiles('*config.php'); | |
$mig->removeFiles('wire/modules/Textformatter/TextformatterMarkdownExtra/markdown.php'); | |
$mig->removeFiles('wire/modules/Textformatter/TextformatterSmartypants/smartypants.php'); | |
$mig->scanFiles(); | |
$file = @$_GET['file']; | |
if (!empty($file)) | |
{ | |
$mig->warnings = array(); | |
$mig->loadFile($file); | |
$mig->fixReferences(); | |
if (@$_GET['debug']) { | |
?> | |
<html> | |
<head> | |
<title>Diagnostics</title> | |
</head> | |
<body> | |
<h1>Diagnostics for <?=$file?></h1> | |
<? if (count($mig->warnings)) { ?> | |
<h2>Warnings</h2> | |
<ul> | |
<? foreach ($mig->warnings as $warning) { ?> | |
<li><?=htmlspecialchars($warning)?></li> | |
<? } ?> | |
</ul> | |
<? } ?> | |
<h2>Parser Dump</h2> | |
<? $mig->dump(); ?> | |
</body> | |
</html> | |
<? | |
} else { | |
header('Content-type: text/plain'); | |
echo $mig->getScript(); | |
} | |
} | |
else if (isset($_POST['action'])) | |
{ | |
header('Content-type: text/plain'); | |
switch ($_POST['action']) | |
{ | |
case 'run': | |
echo "=== Run Conversion ===\n\n"; | |
foreach ($mig->files as $file) { | |
echo "- {$file}\n"; | |
$bak_file = $file.'.bak'; | |
if (file_exists($bak_file)) { | |
die("ERROR: existing backup file '{$bak_file}' is in the way - aborting."); | |
} | |
if (false === @copy($file, $bak_file)) { | |
die("ERROR: unable to create backup file '{$bak_file}' - aborting."); | |
} | |
$mig->warnings = array(); | |
$mig->loadFile($file); | |
$mig->fixReferences(); | |
foreach ($mig->warnings as $warning) { | |
echo "* WARNING: {$warning}\n"; | |
} | |
if (false === @file_put_contents($file, $mig->getScript())) { | |
die("ERROR: unable to write file '{$file}' - aborting."); | |
} | |
} | |
break; | |
case 'revert': | |
echo "=== Revert to *.bak-files ===\n\n"; | |
foreach ($mig->files as $file) { | |
$bak_file = $file.'.bak'; | |
if (file_exists($bak_file)) { | |
echo "- {$file}\n"; | |
if (false === @unlink($file)) { | |
die("ERROR: unable to remove backup file '{$bak_file}' - aborting."); | |
} | |
if (false === @rename($bak_file, $file)) { | |
die("ERROR: unable to restore file '{$file}' from backup file '{$bak_file}' - aborting."); | |
} | |
} | |
} | |
break; | |
} | |
echo "\n=== Done ===\n\n"; | |
} | |
else | |
{ | |
?> | |
<html> | |
<head> | |
<title>Index</title> | |
<style type="text/css"> | |
table.names { border-collapse: collapse; } | |
table.names th { background: #d0d0d0; text-align:left; } | |
table.names th, table.names td { border:solid 1px #e0e0e0; vertical-align:top; padding:2px 6px; } | |
table.names td span { color: #707070; } | |
</style> | |
</head> | |
<body> | |
<h1><?= count($mig->files) ?> Files</h1> | |
<ul> | |
<? foreach ($mig->files as $file) { ?> | |
<li><a href="?file=<?=$file?>"><?=$file?></a> [<a href="?file=<?=$file?>&debug=1">check</a>]</li> | |
<? } ?> | |
</ul> | |
<h1><?= count($mig->names) ?> Classes/Interfaces</h1> | |
<table class="names"> | |
<thead> | |
<tr> | |
<th>Old Name</th> | |
<th>New Name</th> | |
</tr> | |
</thead> | |
<tbody> | |
<? foreach ($mig->names as $old => $new) { ?> | |
<tr><td><?=$old?></td><td><?=$new?></td></tr> | |
<? } ?> | |
</tbody> | |
</table> | |
<? if (count($mig->warnings)) { ?> | |
<h1><?= count($mig->warnings) ?> Warnings</h1> | |
<ul> | |
<? foreach ($mig->warnings as $warning) { ?> | |
<li><?=htmlspecialchars($warning) ?></li> | |
<? } ?> | |
</ul> | |
<? } ?> | |
<? if (count($mig->notices)) { ?> | |
<h1><?= count($mig->notices) ?> Notices</h1> | |
<ul> | |
<? foreach ($mig->notices as $notice) { ?> | |
<li><?=htmlspecialchars($notice) ?></li> | |
<? } ?> | |
</ul> | |
<? } ?> | |
<? if (count($mig->corrections)) { ?> | |
<h1><?= count($mig->corrections) ?> Suggested Filename Corrections</h1> | |
<pre style="border:dotted 1px #aaa; padding:10px;"><?= htmlspecialchars(implode("\n", $mig->corrections)) ?></pre> | |
<? } ?> | |
<h1>Conversion</h1> | |
<form method="post"> | |
<p> | |
<button type="submit" name="action" value="run">Run</button> | |
<label><input type="checkbox" name="backup" value="1" checked="checked"/> Create *.bak-files</label> | |
</p> | |
<p> | |
<button type="submit" name="action" value="revert">Revert to *.bak-files</button> | |
</p> | |
</form> | |
</body> | |
</html> | |
<? | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment