Skip to content

Instantly share code, notes, and snippets.

@mindplay-dk
Created June 16, 2012 19:11
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save mindplay-dk/2942266 to your computer and use it in GitHub Desktop.
Save mindplay-dk/2942266 to your computer and use it in GitHub Desktop.
Migrate a PHP codebase to use namespaces.
<?php
/**
* Namespace Migration Script
*
* @author Rasmus Schultz <rasmus@mindplay.dk>
* @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