Saturday 7 April 2007

Rolling your own Phing task

To round off an older article on this blog called "Using the PHP 5 reflection API to keep track of unsolved refactorings" I wanted to automate the following task: collect and log some information about developer marked unsolved refactorings for a single class file or more common multiple files of an whole directory. And as I'm getting more and more acquainted with Phing I wanted to craft this custom task by using it's provided extension possibilities.

In the above mentioned article I outlined how to collect and introspect metadata of methods having a @unsolvedRefactoring in a single given PHP file. The PHP doclet @unsolvedRefactoring is an 'unoffical' convention used here to tag or mark methods as improvable via suitable refactorings and is assumed for the ongoing train of thoughts.

So here is an example of a method marked as containing an unsolved refactoring.

<?php

class RecordshelfController extends Zend_Controller_Action {

...

/**
* @unsolvedRefactoring A longer note stretching over several lines,
* which might log your thoughts at the moment of writing
* this method & something else.
*/
public function saveAction() {
...
}

...
}
The possibility to track unsolved refactorings might be most usefull as a reminder in stress or time pressure situations, but shouldn't influence the developers discipline to refactor weak spots as early as possible. Simply because to much refactorings add to a later refactoring queue might be lessen their importance and might support the unwanted spread of broken windows in the code base.

To build the desired custom task I had to derivate from the provided Task class of Phing and stuff it with mutator methods for each used target attribute of the buildfile task and the main task functionality. The forthcoming buildfile shows the way to make the custom task accessible to the using targets by defining the task and providing a mapping to it's defintion class ExtractOpenRefactoringTask.
<?xml version="1.0" ?>
<project name="own-phing-task" basedir="." default="unsolved-refactoring-analyzer-multiple">

<!-- Define the custom task, defined and located in/at classname -->
<taskdef name="extractUnsolvedRefactoring" classname="phing.tasks.my.tools.ExtractUnsolvedRefactoringsTask"/>

<property name="include.directory.zend" value="${project.basedir}\library\Zend\ZendFramework-0.9.1\library"/>
<property name="include.directory.custom" value="${project.basedir}\library\Recordshelf"/>

<!-- Use a single PHP file -->
<target name ="unsolved-refactoring-analyzer-single">
<extractOpenRefactoring doclet="unsolvedRefactoring"
outfile="unsolved-refactorings"
type="txt"
file="${project.basedir}/examples/Deeper/Rush.php"
includepath="/path/to/include"/>
</target>

<!-- Use a file set of multiple PHP files -->
<target name="unsolved-refactoring-analyzer-multiple">
<extractUnsolvedRefactoring doclet="unsolvedRefactoring"
outfile="unsolved-refactorings"
type="html"
includepath="${include.directory.zend};${include.directory.custom}">
<fileset dir="${project.basedir}/application" >
<include name="**/*.php" />
</fileset>
</extractUnsolvedRefactoring>
</target>

</project>
The custom class can be located under /classes/phing/tasks/my/tools. The extractUnsolvedRefactoring task requires several mandatory attributes which are each mapped to properties of the custom task class. These attributes and their default values are listed in the following table.

NameTypeDescriptionDefaultRequired
docletstringEvery alphanumeric PHP doclet, without a preceding @.unsolvedRefactoringYes
outfilestringThe log file name to collect open or sheduled refactorings.unsolvedRefactoringsYes
typestringThe type to use for the outfile. Available options are txt|xml|html.txtYes
fileFilePath to the file that should be examined for open or sheduled refactorings.txtNo, if using a nested fileset
includepathstringPath to add to PHP's include_path, for resolving dependencies.n/aYes

So now it's time to delve into the ExtractUnsolvedRefactoringTask class which performs the actual work. As mentioned before every attribute of the target definition in the buildfile is mapped to a property of the class and will be set via mutator methods. These properties are crucial to steer the task.

The method findUnsolvedRefactorings does delegate all the work, analyzing the PHP files and extracting the metadata, to the UnsolvedRefactoringsExtractor(formerly know as UnsolvedRefactoringsProcessor) class and stores the results in an internal array. Finally this "collecting" array is persisted to a log file via the generateLogFile method. At this point only logging to an text, xml or html file is provided, but other output formats like csv might be achieved easy. The Html logging or report is generated via a Xsl stylesheet applied on the on the in-memory Xml result set of this task and has to be located in the same directory of the custom task classes.

So with no further deviation and yabbing here comes the code for the ExtractUnsolvedRefactoringTask class.
<?php

require_once('phing/Task.php');
require_once('UnsolvedRefactoringsExtractor.php');

class ExtractUnsolvedRefactoringsTask extends Task {

protected $supportedTypes = array();
protected $doclet = null;
protected $file = null;
protected $filesets = array();
protected $type = null;
protected $outfile = null;
protected $includepath = null;
protected $applicationDirectory = null;

private $unsolvedRefactorings = array();
private $unsolvedTotal = null;
private $xslFile = 'transformation.xsl';

public function init() {
$this->supportedTypes = array('txt', 'html' , 'xml');
$this->fileset = array();
$this->unsolvedRefactorings = array();
$this->unsolvedTotal = 0;
$this->xslFile = 'transformation.xsl';
}

public function setDoclet($str = 'unsolvedRefactoring') {
if(substr($str, 0, 1) == "@") {
$this->doclet = substr($str, 1, strlen($str));
} else {
$this->doclet = $str;
}
}

public function setFile(PhingFile $file) {
$this->file = $file;
}

public function createFileSet() {
$num = array_push($this->filesets, new FileSet());
return $this->filesets[$num-1];
}

public function setType($str = 'txt') {
$this->type = $str;
}

public function setOutfile($str = 'unsolvedRefactorings') {
$this->outfile = $str;
}

public function setIncludepath($str) {
$this->includepath = get_include_path();
$this->includepath.= PATH_SEPARATOR . $str;
set_include_path($this->includepath);
}

public function main() {
if(!isset($this->file) && count($this->filesets) == 0) {
throw new BuildException("Missing either a nested fileset or attribute 'file' set");
}

if(!isset($this->doclet)) {
throw new BuildException("Missing the unsolved refactoring identifying doclet tag attribute");
} else {
print("Using doclet @{$this->doclet}\n");
}

if(!isset($this->outfile)) {
throw new BuildException("Missing the outfile attribute");
}

if(!isset($this->includepath)) {
throw new BuildException("Missing the includepath attribute");
}

if($this->includepath == '') {
throw new BuildException("Missing value for the includepath attribute");
}

if(!isset($this->type)) {
throw new BuildException("Missing the type attribute");
}

if(!in_array($this->type, $this->supportedTypes)) {
throw new BuildException("Unkown type attribute {$this->type} set");
}

if(!@unlink($this->outfile.'.'.$this->type)) {
throw new BuildException("Unable to delete old log file {$this->outfile}.{$this->type}");
}

// process a single file
if($this->file instanceof PhingFile) {
$this->findUnsolvedRefactorings($this->file->getPath());

// process multiple files in a file set
} else {
$project = $this->getProject();

foreach($this->filesets as $fs) {
$ds = $fs->getDirectoryScanner($project);
$files = $ds->getIncludedFiles();
$dir = $fs->getDir($this->project)->getPath();

if($this->applicationDirectory === null) {
$this->applicationDirectory = $fs->getDir($this->project);
}
foreach($files as $file){
$this->findUnsolvedRefactorings($dir.DIRECTORY_SEPARATOR.$file);
}
}
}

print("\nFound methods docletted with @{$this->doclet}: {$this->unsolvedTotal}\n");

if($this->unsolvedTotal > 0) {
$this->generateLogFile();
print("Logged results to {$this->outfile}.{$this->type}\n");
}

}

protected function findUnsolvedRefactorings($file) {

if(($classname = $this->getCallableClassName($file)) !== null) {
print("+ Inspecting class {$classname}\n");
$extractor = new UnsolvedRefactoringsExtractor($file, $classname, $this->doclet);
$extractions = $extractor->getMethodsWithUnsolvedRefactorings();

if(is_array($extractions)) {
$this->unsolvedRefactorings[]['class'] = $classname;
$this->unsolvedRefactorings[count($this->unsolvedRefactorings) - 1]['file'] = $file;
$this->unsolvedRefactorings[count($this->unsolvedRefactorings) - 1]['unsolved'] = $extractions;
}
$this->unsolvedTotal+= count($extractions);
} else {
return false;
}

}
/**
* @unsolvedRefactoring That method is too long, handling too much and is bloated.
*/
protected function generateLogFile() {
if($this->unsolvedRefactorings !== null) {

if($this->type == "xml" || $this->type == "txt") {
$fhandle = fopen($this->outfile.'.'.$this->type, 'a');
if(!fhandle) {
throw new BuildException("Unable to open {$this->outfile}.{$this->type} for writing");
}
}

foreach($this->unsolvedRefactorings as $index => $refactoring) {
if($this->applicationDirectory != null) {
$refactoring['file'] = str_replace($this->applicationDirectory.DIRECTORY_SEPARATOR,
'',
$refactoring['file']);
}

if($this->type == "xml" || $this->type == "html") {
if($index == 0) {
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'."\n";

if($this->applicationDirectory != null) {
$xmlContent.= '<unsolved-refactorings directory="'.$this->applicationDirectory.'"';
$xmlContent.= ' amount-of-unsolved-refactorings="'.$this->unsolvedTotal.'">'."\n";
} else {
$xmlContent.= '<unsolved-refactorings';
$xmlContent.= ' amount-of-unsolved-refactorings="'.$this->unsolvedTotal.'">'."\n";
}
}

$xmlContent.= '<class name="'.$refactoring['class'].'"';
$xmlContent.= ' file="'.$refactoring['file'].'">'."\n";

foreach($refactoring['unsolved'] as $unsolved) {

if(isset($unsolved['notice'])) {
$xmlContent.= '<method startLine="'.$unsolved['startLine'].'"';
$xmlContent.= ' endLine="'.$unsolved['endLine'].'"';
$xmlContent.= ' notice="'.htmlspecialchars($unsolved['notice']).'">';
} else {
$xmlContent.= '<method startLine="'.$unsolved['startLine'].'"';
$xmlContent.= ' endLine="'.$unsolved['endLine'].'"';
$xmlContent.= ' notice="-">';
}
$xmlContent.= $unsolved['method'];
$xmlContent.= '</method>'."\n";
}
$xmlContent.= '</class>'."\n";

} else {

if($index > 0) {
fwrite($fhandle, "\nUnsolved refactorings for {$refactoring['class']}");
fwrite($fhandle, "({$refactoring['file']}):\n");
} else {
fwrite($fhandle, "Unsolved refactorings for {$refactoring['class']}");
fwrite($fhandle, "({$refactoring['file']}):\n");
}

foreach($refactoring['unsolved'] as $unsolved) {
fwrite($fhandle, "+ {$unsolved['method']} Line: {$unsolved['startLine']} - ");
fwrite($fhandle, "{$unsolved['endLine']}");

if(isset($unsolved['notice'])) {
$notice = str_replace("\n","", $unsolved['notice']);
fwrite($fhandle, " Notice: [{$notice}]");
}
fwrite($fhandle,"\n");
}
}
}

if($this->type == "xml" || $this->type == "html") {
$xmlContent.= '</unsolved-refactorings>'."\n";
}

if($this->type == "xml") {
fwrite($fhandle, $xmlContent);
} elseif($this->type == "html") {

$xml = new DOMDocument;
$xml->loadXML($xmlContent);
$xsl = new DOMDocument;

// Get path xsl file relative to the custom phing task
$pathToXslFile = Phing::getResourcePath("phing/tasks/my/tools/{$this->xslFile}");

if(!$xsl->load($pathToXslFile)) {
throw new BuildException("Unable to open {$pathToXslFile} for transformation to html");
}

$proc = new XSLTProcessor;
$proc->importStyleSheet($xsl);
$proc->transformToURI($xml, $this->outfile.'.html');
}

if($this->type == "xml" || $this->type == "txt") {
fclose($fhandle);
}
}
}

private function getCallableClassName($file) {
$content = file_get_contents($file);

$pattern = "/\s+class\s+([A-z_0-9]+)\s+/";
$times = preg_match($pattern, $content, $matches);

$content = null;

if($times === 0 || $times === false) {
return null;
} else {
return $matches[1];
}
}
}
The next code 'snippet' shows the modified UnsolvedRefactoringsExtractor class, which atcually retrieves the metadata of the methods marked as improveable via the @unsolvedRefactoring doclet and this one has to be located in the directory of the custom task class as well.
<?php
class ClassFileNotReadableException extends Exception {

}
/**
* Formerly known as UnsolvedRefactoringsProcessor class.
*/
class UnsolvedRefactoringsExtractor {

private $_classToInspect = NULL;
private $_fileToInspect = NULL;
private $_methodsToRefactor = NULL;
private $_refactoringTag = NULL;

public function __construct($fileToInspect, $classToInspect, $refactoringTag = 'unsolvedRefactoring') {
$this->_fileToInspect = $fileToInspect;
$this->_classToInspect = $classToInspect;
$this->_refactoringTag = $refactoringTag;
$this->_methodsToRefactor = array();
}

private function setClassToInspect($classToInspect) {
$this->_classToInspect = $classToInspect;
}

private function isClassFileReadable() {
if(file_exists($this->_fileToInspect)) {
return true;
}
return false;
}

private function inspectClass() {
if(!$this->isClassFileReadable()) {
throw new ClassFileNotReadableException($this->_fileToInspect . ' is not readable.');
}
include_once($this->_fileToInspect);
$this->getUnsolvedRefactorings(new ReflectionClass($this->_classToInspect));
}

private function getUnsolvedRefactorings($class) {
foreach($class->getMethods() as $method) {

// exclude inherited methods
if($class->getName() === $method->getDeclaringClass()->getName()) {

if($this->isRefactoringTagPresent($method->getDocComment())) {
$this->_methodsToRefactor[]['method'] = $method->getName();
$this->_methodsToRefactor[count($this->_methodsToRefactor) - 1]['startLine'] =
$method->getStartLine();
$this->_methodsToRefactor[count($this->_methodsToRefactor) - 1]['endLine'] =
$method->getEndLine();

if($this->getNotice($method->getDocComment()) != null) {
$this->_methodsToRefactor[count($this->_methodsToRefactor) - 1]['notice'] =
$this->getNotice($method->getDocComment());
}

}
}
}
}

private function isRefactoringTagPresent($docComment) {
$pattern = '/\s+@'.$this->_refactoringTag.'\b/';

if(preg_match($pattern, $docComment, $matches) > 0) {
return true;
}
return false;
}

private function getNotice($docComment) {
$doclets = $this->extractDoclets(trim(str_replace(array('**','*', '/', ' ', '\n'), '', $docComment)));

foreach($doclets as $doclet) {
if($doclet['doclet'] == $this->_refactoringTag) {

if(array_key_exists('description', $doclet)) {
return $doclet['description'];
}
}
}
return null;
}

private function extractDoclets($docCommentCleanedUp) {
$doclets = explode("@", $docCommentCleanedUp);
array_shift($doclets);

foreach($doclets as $index => $doclet) {

if($this->isDocletWithDescription($doclet)) {
$doclets[$index] = array('doclet' => $this->getDocletTag($doclet),
'description' => $this->getDocletDescription($doclet));
} else {
$doclets[$index] = array('doclet' => $doclet);
}
}
return $doclets;
}

private function getDocletDescription($singleDoclet) {
return substr($singleDoclet, stripos($singleDoclet, " ") + 1);
}

private function getDocletTag($singleDoclet) {
return substr($singleDoclet, 0, stripos($singleDoclet, " "));
}

private function isDocletWithDescription($singleDoclet) {
if(stripos($singleDoclet, " ")) {
return true;
} else {
return false;
}
}

public function getMethodsWithUnsolvedRefactorings() {
try {
$this->inspectClass();
} catch (ClassFileNotReadableException $e) {
return $e->getMessage();
}
if($this->classContainsUnsolvedRefactorings()) {
return $this->_methodsToRefactor;
}
return null;
}

public function classContainsUnsolvedRefactorings() {
if(count($this->_methodsToRefactor) > 0) {
return true;
}
return false;
}

public function getInspectedClassName() {
return $this->_classToInspect;
}
}
// needed to resolve heritage hierachies in the relected files/classes
function __autoload($classname) {
$filename = str_replace('_', '/', $classname).'.php';
require($filename);
}

With everything in place it is now possible to run the custom task by calling phing. If your buildfile looks like the one above a simple call to phing will find all unsolved refacorings, otherwise you have to provide the target which crawls for them.

phing unsolved-refactoring-analyzer-multiple

This is how the output of the custom Phing task should look like, indicating which classes were introspected and how many unsolved refactorings where found.

own-phing-task > unsolved-refactoring-analyzer-multiple:
Using doclet @unsolvedRefactoring
+ Inspecting class IndexController
+ Inspecting class LoginController
+ Inspecting class RecordshelfController
+ Inspecting class TourController
+ Inspecting class UserController
+ Inspecting class WishlistController
+ Inspecting class Dj_Set
+ Inspecting class Record_Playlist
+ Inspecting class Record_Shelf
+ Inspecting class Record_Wishlist
+ Inspecting class Record
+ Inspecting class User
+ Inspecting class Utilities

Found methods docletted with @unsolvedRefactoring: 4
Logged results to unsolved-refactorings.html

As you can see after an successfull execution of the unsolved-refactoring-analyzer-multiple target there will be a log or report file located in the base directory of the buildfile. This file will contain the collected information(methodname, start and end line, notes made by the developer while recognizing the need for a refactoring) about developer tagged @unsolvedRefactorings in every inspected class file.

So here is an example of a generated Html report summarizing all found results.

Unsolved refactoring report

The above Html report is generated via this Xsl stylesheet, which can be easily customized to change the appearance of the report.
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:fo="http://www.w3.org/1999/XSL/Format">
<xsl:template match="/">
<html>
<head>
<title>Unsolved refactoring report</title>
<style type="text/css">
.box {
font-size: 11px;
padding: 3px;
border: 1px solid #000;
}
.text {
font-size: 11px;
text-indent: 1px;
margin: 1px;
}
.comment {
text-indent: 1px;
margin: 1px;
}
.headline {
font-weight: bold;
}
.unsolvedRefactoring {
background-color: #CE6700;
}
.intro {
font-size: 11px;
text-indent: 1px;
padding-bottom: 5px;
font-weight: bold;
}
body {
font-family : Verdana, Geneva, Arial, Helvetica, sans-serif;
padding-left: 10px;
}
</style>
</head>
<body>
<div class="intro">
<xsl:value-of select="//@amount-of-unsolved-refactorings"/>unsolved refactorings in
<xsl:value-of select="//@directory"/>
</div>
<table class="text" width="70%" border="0">
<xsl:for-each select="unsolved-refactorings/class">
<!-- Information about the method hosting class -->
<tr class="headline">
<td colspan="3" class="box" valign="top">
<xsl:value-of select="./@name"/> [<xsl:value-of select="./@file"/>]
</td>
</tr>
<!-- Apply xsl template for method entities -->
<xsl:apply-templates />
</xsl:for-each>
</table>
</body>
</html>
</xsl:template>

<xsl:template match="method">
<!-- Meta information about method with unsolved refactoring -->
<tr>
<td width="15%" class="unsolvedRefactoring box" valign="top">
<b><xsl:value-of select="."/></b>
</td>
<td class="box" width="15%" valign="top">
Line <xsl:value-of select="@startLine"/> - <xsl:value-of select="@endLine"/>
</td>
<td class="box" width="70%" valign="top">
<xsl:value-of select="@notice"/>
</td>
</tr>
</xsl:template>
</xsl:stylesheet>